From 895e91ca5efca3cfb7c27d7d290712f9e8f1ef98 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:32:02 +0200 Subject: [PATCH 001/980] Initial commit --- .coveragerc | 5 + .editorconfig | 17 + .flake8 | 3 + .gitignore | 65 +++ LICENSE | 661 ++++++++++++++++++++++++++ Makefile | 19 + README.md | 22 + docker-compose.yml | 20 + docker/ucs/Dockerfile | 5 + docker/ucs/README.md | 12 + docker/ucs/scripts/create-new-user.sh | 32 ++ docker/ucs/scripts/init.sh | 6 + docker/ucs/timed-ucs.profile | 9 + manage.py | 10 + pytest.ini | 2 + setup.py | 50 ++ timed/.gitignore | 1 + timed/__init__.py | 0 timed/config.sample.ini | 16 + timed/jsonapi_test_case.py | 57 +++ timed/middleware.py | 3 + timed/settings.py | 221 +++++++++ timed/urls.py | 10 + timed/wsgi.py | 16 + timed_api/__init__.py | 0 timed_api/admin.py | 42 ++ timed_api/apps.py | 5 + timed_api/factories.py | 5 + timed_api/filters.py | 79 +++ timed_api/migrations/0001_initial.py | 117 +++++ timed_api/migrations/__init__.py | 0 timed_api/models.py | 118 +++++ timed_api/serializers.py | 169 +++++++ timed_api/tests/__init__.py | 0 timed_api/tests/test_test.py | 7 + timed_api/urls.py | 22 + timed_api/views.py | 152 ++++++ 37 files changed, 1978 insertions(+) create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docker/ucs/Dockerfile create mode 100644 docker/ucs/README.md create mode 100755 docker/ucs/scripts/create-new-user.sh create mode 100755 docker/ucs/scripts/init.sh create mode 100644 docker/ucs/timed-ucs.profile create mode 100755 manage.py create mode 100644 pytest.ini create mode 100644 setup.py create mode 100644 timed/.gitignore create mode 100644 timed/__init__.py create mode 100644 timed/config.sample.ini create mode 100644 timed/jsonapi_test_case.py create mode 100644 timed/middleware.py create mode 100644 timed/settings.py create mode 100644 timed/urls.py create mode 100644 timed/wsgi.py create mode 100644 timed_api/__init__.py create mode 100644 timed_api/admin.py create mode 100644 timed_api/apps.py create mode 100644 timed_api/factories.py create mode 100644 timed_api/filters.py create mode 100644 timed_api/migrations/0001_initial.py create mode 100644 timed_api/migrations/__init__.py create mode 100644 timed_api/models.py create mode 100644 timed_api/serializers.py create mode 100644 timed_api/tests/__init__.py create mode 100644 timed_api/tests/test_test.py create mode 100644 timed_api/urls.py create mode 100644 timed_api/views.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..b1f30cccb --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +source=timed_api,timed + +[report] +omit=*/migrations/*.py,*/tests/*.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..229c447c1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 + +[*.py] +indent_style = space +indent_size = 4 + +[*.json] +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..63d33aa92 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +ignore = E221,E241,E272,E251,W702,E203,E272,E201,E202,F403 +exclude = migrations,__pycache__,Makefile diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..219b8fa50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Ipython Notebook +.ipynb_checkpoints + +# Pyenv +.python-version diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..197242a90 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + timed-backend.src + Copyright (C) 2016 ad-sy + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..df8a0001e --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: setup-ldap create-ldap-user + +install: + pip install -e . + +test: test-lint test-coverage + +test-coverage: + py.test --cov + +test-lint: + flake8 + +setup-ldap: + docker exec -it timedbackendsrc_ucs_1 /usr/lib/univention-system-setup/scripts/setup-join.sh + docker exec -it timedbackendsrc_ucs_1 /usr/ucs/scripts/init.sh + +create-ldap-user: + docker exec -it timedbackendsrc_ucs_1 /usr/ucs/scripts/create-new-user.sh diff --git a/README.md b/README.md new file mode 100644 index 000000000..d707625f0 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Timed (Backend) +## Installation +**Requirements** +* Python 3.5.1 +* docker +* docker-compose + +After installing and configuring those requirements, you should be able to run the following +commands to complete the installation: +```bash +$ make install # Install Python requirements +$ docker-compose up -d # Start the containers +$ make setup-ldap # Configure UCS LDAP container +$ make create-ldap-user # Create a new standard user +$ ./manage.py migrate # Run Django migrations +$ ./manage.py createsuperuser # Create a new Django superuser +``` + +You can now access the API at http://localhost:8000/api/v1 and the admin panel at http://localhost:8000/admin/ + +## Testing +Run tests by executing `make test` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..21799884b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '2' +services: + db: + image: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=timed + - POSTGRES_PASSWORD=timed + ucs: + build: ./docker/ucs + hostname: timed-ucs + volumes: + - ./docker/ucs/timed-ucs.profile:/var/cache/univention-system-setup/profile + - ./docker/ucs/scripts:/usr/ucs/scripts + ports: + - "389:389" + - "8080:80" + environment: + - rootpwd=univention diff --git a/docker/ucs/Dockerfile b/docker/ucs/Dockerfile new file mode 100644 index 000000000..0057248c7 --- /dev/null +++ b/docker/ucs/Dockerfile @@ -0,0 +1,5 @@ +FROM univention/ucs-master-amd64 + +EXPOSE 80 389 636 + +ENTRYPOINT [ "/sbin/init" ] diff --git a/docker/ucs/README.md b/docker/ucs/README.md new file mode 100644 index 000000000..a8b4805bf --- /dev/null +++ b/docker/ucs/README.md @@ -0,0 +1,12 @@ +After running the ucs container, launch this command: + +```sh +$ docker exec backend_ucs_1 /usr/lib/univention-system-setup/scripts/setup-join.sh +``` + +To add some dummy data for testing, use: + +```sh +$ docker exec backend_ucs_1 /usr/ucs/scripts/fill-dummy-data.sh +``` + diff --git a/docker/ucs/scripts/create-new-user.sh b/docker/ucs/scripts/create-new-user.sh new file mode 100755 index 000000000..56ad37ae2 --- /dev/null +++ b/docker/ucs/scripts/create-new-user.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +echo "First Name:" +read firstname +echo "Last Name:" +read lastname + +firstname_l="$(echo $firstname | tr '[:upper:]' '[:lower:]')" +lastname_l="$(echo $lastname | tr '[:upper:]' '[:lower:]')" +email="$firstname_l.$lastname_l@adfinis-sygroup.ch" +username="$firstname_l$(echo $lastname_l | head -c 1)" +password="123qweasd" + +udm users/user create \ + --position="cn=users,$(ucr get ldap/base)" \ + --set username="$username" \ + --set firstname="$firstname" \ + --set lastname="$lastname" \ + --set password="$password"\ + --set description="$firstname $lastname" \ + --set e-mail="$email" \ + --set shell="/bin/bash" \ + --set primaryGroup="cn=adsy-user,cn=groups,$(ucr get ldap/base)" + +echo "" +echo "Name: $firstname $lastname" +echo "Username: $username" +echo "Passwort: $password" +echo "Email: $email" +echo "" + +exit diff --git a/docker/ucs/scripts/init.sh b/docker/ucs/scripts/init.sh new file mode 100755 index 000000000..64101c921 --- /dev/null +++ b/docker/ucs/scripts/init.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +udm groups/group create \ + --set name="adsy-user" \ + --set description="adsy user" \ + --position="cn=groups,$(ucr get ldap/base)" diff --git a/docker/ucs/timed-ucs.profile b/docker/ucs/timed-ucs.profile new file mode 100644 index 000000000..3f2948773 --- /dev/null +++ b/docker/ucs/timed-ucs.profile @@ -0,0 +1,9 @@ +hostname="timed-ucs" +domainname="adsy-ext.becs.adfinis-sygroup.ch" +windows/domain="ADSY-EXT" +ldap/base="dc=adsy-ext,dc=becs,dc=adfinis-sygroup,dc=ch" +root_password="univention" + +server/role="domaincontroller_master" + +interfaces/eth0/type="dynamic" diff --git a/manage.py b/manage.py new file mode 100755 index 000000000..8ce2c11b0 --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timed.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..c5f16e220 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE=timed.settings diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..1447e4162 --- /dev/null +++ b/setup.py @@ -0,0 +1,50 @@ +"""Setuptools package definition""" + +from setuptools import setup +import codecs + +with codecs.open("README.md", "r", encoding="UTF-8") as f: + README_TEXT = f.read() + +setup( + name = "timed", + version = "0.0.0", + entry_points = { + "console_scripts": [ + ] + }, + install_requires = [ + "django==1.9", + "django-auth-ldap==1.2.8", + "djangorestframework==3.4.1", + "djangorestframework-jsonapi==2.0.1", + "djangorestframework-jwt==1.8.0", + "django-filter==0.13", + "django-crispy-forms==1.6.0", + "flake8", + "coverage", + "pytest", + "pytest-django", + "pytest-cov", + "factory-boy==2.7.0", + "psycopg2", + "ipdb" + ], + author = "Adfinis SyGroup AG", + author_email = "https://adfinis-sygroup.ch/", + description = "Timetracking software", + long_description = README_TEXT, + keywords = "timetracking", + url = "https://adfinis-sygroup.ch/", + classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: " + "GNU Affero General Public License v3", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.5.1", + ] +) diff --git a/timed/.gitignore b/timed/.gitignore new file mode 100644 index 000000000..2fa7ce7c4 --- /dev/null +++ b/timed/.gitignore @@ -0,0 +1 @@ +config.ini diff --git a/timed/__init__.py b/timed/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/config.sample.ini b/timed/config.sample.ini new file mode 100644 index 000000000..165ffe0fe --- /dev/null +++ b/timed/config.sample.ini @@ -0,0 +1,16 @@ +[ldap] +AUTH_LDAP_SERVER_URI = ldap://localhost:389 +AUTH_LDAP_BIND_DN = uid=Administrator,cn=users,dc=example,dc=com +AUTH_LDAP_PASSWORD = ********** +AUTH_LDAP_USER_DN_TEMPLATE = uid=%%(user)s,cn=users,dc=example,dc=com + +[github] +GITHUB_API_URL = https://api.github.com/repos/{}/issues +GITHUB_ISSUE_URL = https://github.com/{}/issues/{} + +[redmine] +REDMINE_API_URL = https://redmine.example.com/projects/{}/issues.json +REDMINE_ISSUE_URL = https://redmine.example.com/issues/{} +REDMINE_BASIC_AUTH = true +REDMINE_BASIC_AUTH_USER = admin +REDMINE_BASIC_AUTH_PASSWORD = ********** diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py new file mode 100644 index 000000000..6cbf32794 --- /dev/null +++ b/timed/jsonapi_test_case.py @@ -0,0 +1,57 @@ +from rest_framework.test import APITestCase, APIClient + +import json + + +class JSONAPIClient(APIClient): + + def __init__(self, *args, **kwargs): + super(JSONAPIClient, self).__init__(*args, **kwargs) + + self._content_type = 'application/vnd.api+json' + + def _parse_data(self, data): + return json.dumps(data) if data else data + + def get(self, path, data=None, **extra): + return super(JSONAPIClient, self).get( + path=path, + data=self._parse_data(data), + content_type=self._content_type, + **extra + ) + + def post(self, path, data=None, **extra): + return super(JSONAPIClient, self).post( + path=path, + data=self._parse_data(data), + content_type=self._content_type, + **extra + ) + + def delete(self, path, data=None, **extra): + return super(JSONAPIClient, self).delete( + path=path, + data=self._parse_data(data), + content_type=self._content_type, + **extra + ) + + def patch(self, path, data=None, **extra): + return super(JSONAPIClient, self).patch( + path=path, + data=self._parse_data(data), + content_type=self._content_type, + **extra + ) + + +class JSONAPITestCase(APITestCase): + + def setUp(self): + super(JSONAPITestCase, self).setUp() + + self.client = JSONAPIClient() + + def result(self, response): + return json.loads(response.content.decode('utf8')) diff --git a/timed/middleware.py b/timed/middleware.py new file mode 100644 index 000000000..73a0f034d --- /dev/null +++ b/timed/middleware.py @@ -0,0 +1,3 @@ +class DisableCSRFMiddleware(object): + def process_request(self, request): + setattr(request, '_dont_enforce_csrf_checks', True) diff --git a/timed/settings.py b/timed/settings.py new file mode 100644 index 000000000..4033b9878 --- /dev/null +++ b/timed/settings.py @@ -0,0 +1,221 @@ +""" +Django settings for timed project. + +Generated by 'django-admin startproject' using Django 1.9.2. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os +import datetime +import configparser + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +config = configparser.ConfigParser() +config.read(os.path.join(BASE_DIR, 'timed/config.ini')) + +ldap_config = config['ldap'] +github_config = config['github'] +redmine_config = config['redmine'] + + +def trueish(value): + return value.lower() in ( 'true', '1', 'yes' ) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'jpfx&3nyjat!)g1vbp7n=#6cgeu*vnwyymxehm-%jc+482%^ej' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'crispy_forms', + 'timed_api', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'timed.middleware.DisableCSRFMiddleware', +] + +ROOT_URLCONF = 'timed.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 = 'timed.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'timed', + 'USER': 'timed', + 'PASSWORD': 'timed', + 'HOST': 'localhost' + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'de-CH' + +TIME_ZONE = 'Europe/Zurich' + +USE_I18N = True +USE_L10N = False + +DATETIME_FORMAT = 'd.m.Y H:i:s' +DATE_FORMAT = 'd.m.Y' +TIME_FORMAT = 'H:i:s' + +DECIMAL_SEPARATOR = '.' + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' + +REST_FRAMEWORK = { + 'ORDERING_PARAM': 'sort', + 'PAGINATE_BY': None, + 'PAGINATE_BY_PARAM': 'page_size', + 'MAX_PAGINATE_BY': 100, + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter', + ), + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework_json_api.parsers.JSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser' + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', + ), + 'DEFAULT_METADATA_CLASS': + 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'EXCEPTION_HANDLER': + 'rest_framework_json_api.exceptions.exception_handler', + 'DEFAULT_PAGINATION_CLASS': + 'rest_framework_json_api.pagination.PageNumberPagination', + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework_json_api.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', + ), +} + +AUTHENTICATION_BACKENDS = ( + 'django_auth_ldap.backend.LDAPBackend', + 'django.contrib.auth.backends.ModelBackend', +) + +JWT_AUTH = { + 'JWT_EXPIRATION_DELTA': datetime.timedelta(minutes=60), + 'JWT_ALLOW_REFRESH': True, + 'JWT_AUTH_HEADER_PREFIX': 'Bearer', +} + +JSON_API_FORMAT_KEYS = 'dasherize' +JSON_API_FORMAT_TYPES = 'dasherize' +JSON_API_PLURALIZE_TYPES = True + +APPEND_SLASH = False + +GITHUB_API_URL = github_config.get('GITHUB_API_URL') +GITHUB_ISSUE_URL = github_config.get('GITHUB_ISSUE_URL') + +REDMINE_API_URL = redmine_config.get('REDMINE_API_URL') +REDMINE_ISSUE_URL = redmine_config.get('REDMINE_ISSUE_URL') +REDMINE_BASIC_AUTH = trueish(redmine_config.get('REDMINE_BASIC_AUTH')) +REDMINE_BASIC_AUTH_USER = redmine_config.get('REDMINE_BASIC_AUTH_USER') +REDMINE_BASIC_AUTH_PASSWORD = redmine_config.get('REDMINE_BASIC_AUTH_PASSWORD') + +import ipdb +ipdb.set_trace() + +AUTH_LDAP_ALWAYS_UPDATE_USER = True + +AUTH_LDAP_USER_ATTR_MAP = { + 'first_name': 'givenName', + 'last_name': 'sn', + 'email': 'mail' +} + +AUTH_LDAP_SERVER_URI = ldap_config.get('AUTH_LDAP_SERVER_URI') +AUTH_LDAP_BIND_DN = ldap_config.get('AUTH_LDAP_BIND_DN') +AUTH_LDAP_BIND_PASSWORD = ldap_config.get('AUTH_LDAP_BIND_PASSWORD') +AUTH_LDAP_USER_DN_TEMPLATE = ldap_config.get('AUTH_LDAP_USER_DN_TEMPLATE') diff --git a/timed/urls.py b/timed/urls.py new file mode 100644 index 000000000..11a99bafb --- /dev/null +++ b/timed/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url, include +from django.contrib import admin +from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token + +urlpatterns = [ + url(r'^admin/', admin.site.urls), + url(r'^api/v1/auth/login', obtain_jwt_token), + url(r'^api/v1/auth/refresh', refresh_jwt_token), + url(r'^api/v1/', include('timed_api.urls')) +] diff --git a/timed/wsgi.py b/timed/wsgi.py new file mode 100644 index 000000000..7e15165e2 --- /dev/null +++ b/timed/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for timed project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timed.settings") + +application = get_wsgi_application() diff --git a/timed_api/__init__.py b/timed_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed_api/admin.py b/timed_api/admin.py new file mode 100644 index 000000000..20835d614 --- /dev/null +++ b/timed_api/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from timed_api import models + + +@admin.register(models.Activity) +class ActivityAdmin(admin.ModelAdmin): + list_display = [ 'task', 'comment', 'duration' ] + + +@admin.register(models.ActivityBlock) +class ActivityBlockAdmin(admin.ModelAdmin): + list_display = [ 'from_datetime', 'to_datetime', 'duration' ] + + +@admin.register(models.Attendance) +class AttendanceAdmin(admin.ModelAdmin): + list_display = [ 'from_datetime', 'to_datetime' ] + + +@admin.register(models.Report) +class ReportAdmin(admin.ModelAdmin): + list_display = [ 'user', 'comment' ] + + +@admin.register(models.Customer) +class CustomerAdmin(admin.ModelAdmin): + list_display = [ 'name', 'email', 'website' ] + + +@admin.register(models.Project) +class ProjectAdmin(admin.ModelAdmin): + list_display = [ 'name' ] + + +@admin.register(models.Task) +class TaskAdmin(admin.ModelAdmin): + list_display = [ 'name' ] + + +@admin.register(models.TaskTemplate) +class TaskTemplateAdmin(admin.ModelAdmin): + list_display = [ 'name' ] diff --git a/timed_api/apps.py b/timed_api/apps.py new file mode 100644 index 000000000..07bbc6d93 --- /dev/null +++ b/timed_api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TimedApiConfig(AppConfig): + name = 'timed_api' diff --git a/timed_api/factories.py b/timed_api/factories.py new file mode 100644 index 000000000..16de4fef0 --- /dev/null +++ b/timed_api/factories.py @@ -0,0 +1,5 @@ +# import factory + +# from timed_api import models + +# TODO diff --git a/timed_api/filters.py b/timed_api/filters.py new file mode 100644 index 000000000..f2af88e72 --- /dev/null +++ b/timed_api/filters.py @@ -0,0 +1,79 @@ +import datetime + +from functools import wraps +from timed_api import models +from django_filters import FilterSet, Filter +from django.contrib.auth.models import User + + +def boolean_filter(func): + @wraps(func) + def wrapper(self, qs, value): + if value.lower() not in ( '1', 'true', 'yes' ): + return qs + + return func(self, qs, value) + + return wrapper + + +class TodayFilter(Filter): + @boolean_filter + def filter(self, qs, value): + return qs.filter(**{ + '%s__date' % self.name: datetime.date.today() + }) + + +class ActivityActiveFilter(Filter): + @boolean_filter + def filter(self, qs, value): + return qs.filter(blocks__to_datetime__exact=None) + + +class UserFilterSet(FilterSet): + class Meta: + model = User + + +class ActivityFilterSet(FilterSet): + active = ActivityActiveFilter() + today = TodayFilter(name='start_datetime') + + class Meta: + model = models.Activity + + +class ActivityBlockFilterSet(FilterSet): + class Meta: + model = models.ActivityBlock + + +class AttendanceFilterSet(FilterSet): + class Meta: + model = models.Attendance + + +class ReportFilterSet(FilterSet): + class Meta: + model = models.Report + + +class CustomerFilterSet(FilterSet): + class Meta: + model = models.Customer + + +class ProjectFilterSet(FilterSet): + class Meta: + model = models.Project + + +class TaskFilterSet(FilterSet): + class Meta: + model = models.Task + + +class TaskTemplateFilterSet(FilterSet): + class Meta: + model = models.TaskTemplate diff --git a/timed_api/migrations/0001_initial.py b/timed_api/migrations/0001_initial.py new file mode 100644 index 000000000..735e76a4a --- /dev/null +++ b/timed_api/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-08-11 13:19 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Activity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.CharField(blank=True, max_length=255)), + ('start_datetime', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='ActivityBlock', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_datetime', models.DateTimeField(auto_now_add=True)), + ('to_datetime', models.DateTimeField(blank=True, null=True)), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='timed_api.Activity')), + ], + ), + migrations.CreateModel( + name='Attendance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_datetime', models.DateTimeField()), + ('to_datetime', models.DateTimeField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('email', models.EmailField(blank=True, max_length=254)), + ('website', models.URLField(blank=True)), + ('comment', models.TextField(blank=True)), + ('archived', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('comment', models.TextField(blank=True)), + ('archived', models.BooleanField(default=False)), + ('tracker_type', models.CharField(blank=True, choices=[('GH', 'Github'), ('RM', 'Redmine')], max_length=2)), + ('tracker_name', models.CharField(blank=True, max_length=255)), + ('tracker_api_key', models.CharField(blank=True, max_length=255)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='timed_api.Customer')), + ('leaders', models.ManyToManyField(blank=True, related_name='projects', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Report', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.CharField(max_length=255)), + ('duration', models.DurationField()), + ('review', models.BooleanField(default=False)), + ('nta', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('estimated_hours', models.PositiveIntegerField(blank=True, null=True)), + ('archived', models.BooleanField(default=False)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='timed_api.Project')), + ], + ), + migrations.CreateModel( + name='TaskTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ], + ), + migrations.AddField( + model_name='report', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='timed_api.Task'), + ), + migrations.AddField( + model_name='report', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='activity', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='timed_api.Task'), + ), + migrations.AddField( + model_name='activity', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed_api/migrations/__init__.py b/timed_api/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed_api/models.py b/timed_api/models.py new file mode 100644 index 000000000..c9e2803e2 --- /dev/null +++ b/timed_api/models.py @@ -0,0 +1,118 @@ +from django.db import models +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver +from datetime import timedelta + + +class Activity(models.Model): + comment = models.CharField(max_length=255, blank=True) + start_datetime = models.DateTimeField(auto_now_add=True) + task = models.ForeignKey('Task', related_name='activities') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='activities' + ) + + @property + def duration(self): + durations = [ + block.duration + for block + in self.blocks.all() + if block.duration + ] + + return sum(durations, timedelta()) + + +class ActivityBlock(models.Model): + activity = models.ForeignKey('Activity', related_name='blocks') + from_datetime = models.DateTimeField(auto_now_add=True) + to_datetime = models.DateTimeField(blank=True, null=True) + + @property + def duration(self): + if not self.to_datetime: + return None + + return self.to_datetime - self.from_datetime + + +class Attendance(models.Model): + from_datetime = models.DateTimeField() + to_datetime = models.DateTimeField() + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='attendances' + ) + + +class Report(models.Model): + comment = models.CharField(max_length=255) + duration = models.DurationField() + review = models.BooleanField(default=False) + nta = models.BooleanField(default=False) + task = models.ForeignKey('Task', related_name='reports') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='reports' + ) + + +class Customer(models.Model): + name = models.CharField(max_length=255) + email = models.EmailField(blank=True) + website = models.URLField(blank=True) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) + + def __str__(self): + return self.name + + +class Project(models.Model): + TYPES = ( + ('GH', 'Github'), + ('RM', 'Redmine') + ) + + name = models.CharField(max_length=255) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) + tracker_type = models.CharField(max_length=2, choices=TYPES, blank=True) + tracker_name = models.CharField(max_length=255, blank=True) + tracker_api_key = models.CharField(max_length=255, blank=True) + customer = models.ForeignKey('Customer', related_name='projects') + leaders = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name='projects', + blank=True + ) + + def __str__(self): + return self.name + + +class Task(models.Model): + name = models.CharField(max_length=255) + estimated_hours = models.PositiveIntegerField(blank=True, null=True) + archived = models.BooleanField(default=False) + project = models.ForeignKey('Project', related_name='tasks') + + def __str__(self): + return self.name + + +class TaskTemplate(models.Model): + name = models.CharField(max_length=255) + + def __str__(self): + return self.name + + +@receiver(post_save, sender=Project) +def create_default_tasks(sender, instance, created, **kwargs): + if created: + for template in TaskTemplate.objects.all(): + Task.objects.create(name=template.name, project=instance) diff --git a/timed_api/serializers.py b/timed_api/serializers.py new file mode 100644 index 000000000..b62ca15f9 --- /dev/null +++ b/timed_api/serializers.py @@ -0,0 +1,169 @@ +from timed_api import models +from django.contrib.auth.models import User +from rest_framework_json_api import serializers +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import ModelSerializer + + +class UserSerializer(ModelSerializer): + projects = ResourceRelatedField( + queryset=models.Project.objects.all(), + many=True + ) + + attendances = ResourceRelatedField( + queryset=models.Attendance.objects.all(), + many=True + ) + + class Meta: + model = User + fields = [ + 'username', + 'first_name', + 'last_name', + 'email', + 'projects', + 'attendances', + ] + + +class ActivitySerializer(ModelSerializer): + duration = serializers.DurationField(read_only=True) + + task = ResourceRelatedField( + queryset=models.Task.objects.all() + ) + + user = ResourceRelatedField( + queryset=User.objects.all(), + allow_null=True, + required=False + ) + + blocks = ResourceRelatedField( + read_only=True, + many=True, + ) + + class Meta: + model = models.Activity + fields = [ + 'comment', + 'start_datetime', + 'duration', + 'task', + 'user', + 'blocks', + ] + + +class ActivityBlockSerializer(ModelSerializer): + activity = ResourceRelatedField( + queryset=models.Activity.objects.all() + ) + + class Meta: + model = models.ActivityBlock + + +class AttendanceSerializer(ModelSerializer): + class Meta: + model = models.Attendance + + +class ReportSerializer(ModelSerializer): + task = ResourceRelatedField( + queryset=models.Task.objects.all() + ) + + user = ResourceRelatedField( + queryset=User.objects.all() + ) + + class Meta: + model = models.Report + + +class CustomerSerializer(ModelSerializer): + projects = ResourceRelatedField( + queryset=models.Project.objects.all(), + many=True + ) + + class Meta: + model = models.Customer + + +class ProjectSerializer(ModelSerializer): + customer = ResourceRelatedField( + queryset=models.Customer.objects.all() + ) + + leaders = ResourceRelatedField( + queryset=User.objects.all(), + many=True + ) + + tasks = ResourceRelatedField( + queryset=models.Task.objects.all(), + required=False, + many=True + ) + + class Meta: + model = models.Project + + +class TaskSerializer(ModelSerializer): + activities = ResourceRelatedField( + queryset=models.Activity.objects.all(), + many=True + ) + + project = ResourceRelatedField( + queryset=models.Project.objects.all() + ) + + class Meta: + model = models.Task + + +class TaskTemplateSerializer(ModelSerializer): + class Meta: + model = models.TaskTemplate + + +UserSerializer.included_serializers = { + 'projects': ProjectSerializer +} + +ActivitySerializer.included_serializers = { + 'blocks': ActivityBlockSerializer, + 'task': TaskSerializer, + 'user': UserSerializer +} + +ActivityBlockSerializer.included_serializers = { + 'activity': ActivitySerializer +} + +ReportSerializer.included_serializers = { + 'task': TaskSerializer, + 'user': UserSerializer +} + +CustomerSerializer.included_serializers = { + 'projects': ProjectSerializer +} + +ProjectSerializer.included_serializers = { + 'customer': CustomerSerializer, + 'leaders': UserSerializer, + 'tasks': TaskSerializer +} + +TaskSerializer.included_serializers = { + 'activities': ActivitySerializer, + 'project': ProjectSerializer +} diff --git a/timed_api/tests/__init__.py b/timed_api/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed_api/tests/test_test.py b/timed_api/tests/test_test.py new file mode 100644 index 000000000..cb7561341 --- /dev/null +++ b/timed_api/tests/test_test.py @@ -0,0 +1,7 @@ +from timed.jsonapi_test_case import JSONAPITestCase + + +class TestTests(JSONAPITestCase): + + def test_test(self): + self.assertTrue(True) diff --git a/timed_api/urls.py b/timed_api/urls.py new file mode 100644 index 000000000..9613d4ab3 --- /dev/null +++ b/timed_api/urls.py @@ -0,0 +1,22 @@ +from timed_api import views +from rest_framework.routers import SimpleRouter +from django.conf import settings +from django.conf.urls import url + +r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) + +r.register(r'users', views.UserViewSet, 'user') +r.register(r'activities', views.ActivityViewSet, 'activity') +r.register(r'activity-blocks', views.ActivityBlockViewSet, 'activity-block') +r.register(r'attendances', views.AttendanceViewSet, 'attendance') +r.register(r'reports', views.ReportViewSet, 'report') +r.register(r'projects', views.ProjectViewSet, 'project') +r.register(r'customers', views.CustomerViewSet, 'customer') +r.register(r'tasks', views.TaskViewSet, 'task') +r.register(r'task-templates', views.TaskTemplateViewSet, 'task-template') + +urlpatterns = [ + url(r'projects/(?P[0-9]+)/issues', views.ProjectIssuesView.as_view()) +] + +urlpatterns.extend(r.urls) diff --git a/timed_api/views.py b/timed_api/views.py new file mode 100644 index 000000000..be37a798d --- /dev/null +++ b/timed_api/views.py @@ -0,0 +1,152 @@ +import requests + +from timed_api import serializers, models, filters +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.views import APIView +from rest_framework.response import Response +from django.contrib.auth.models import User +from django.conf import settings +from base64 import b64_encode + + +class UserViewSet(ReadOnlyModelViewSet): + queryset = User.objects.all() + serializer_class = serializers.UserSerializer + filter_class = filters.UserFilterSet + + +class ActivityViewSet(ModelViewSet): + serializer_class = serializers.ActivitySerializer + filter_class = filters.ActivityFilterSet + + def get_queryset(self): + return models.Activity.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class ActivityBlockViewSet(ModelViewSet): + serializer_class = serializers.ActivityBlockSerializer + filter_class = filters.ActivityBlockFilterSet + + def get_queryset(self): + return models.ActivityBlock.objects.filter( + activity__user=self.request.user + ) + + +class AttendanceViewSet(ModelViewSet): + queryset = models.Attendance.objects.all() + serializer_class = serializers.ActivitySerializer + filter_class = filters.AttendanceFilterSet + + +class ReportViewSet(ModelViewSet): + queryset = models.Report.objects.all() + serializer_class = serializers.ReportSerializer + filter_class = filters.ReportFilterSet + + +class CustomerViewSet(ModelViewSet): + queryset = models.Customer.objects.filter(archived=False) + serializer_class = serializers.CustomerSerializer + filter_class = filters.CustomerFilterSet + search_fields = ('name',) + ordering = 'name' + + +class ProjectViewSet(ModelViewSet): + queryset = models.Project.objects.filter(archived=False) + serializer_class = serializers.ProjectSerializer + filter_class = filters.ProjectFilterSet + search_fields = ('name', 'customer__name',) + ordering = ('customer__name', 'name') + + +class TaskViewSet(ModelViewSet): + queryset = models.Task.objects.all() + serializer_class = serializers.TaskSerializer + filter_class = filters.TaskFilterSet + + +class TaskTemplateViewSet(ModelViewSet): + queryset = models.TaskTemplate.objects.all() + serializer_class = serializers.TaskTemplateSerializer + filter_class = filters.TaskTemplateFilterSet + + +class ProjectIssuesView(APIView): + + def get_github_issues(self, project): + url = settings.GITHUB_API_URL.format(project.tracker_name) + + response = requests.get(url, headers={ + 'Authorization': 'token {}'.format(project.tracker_api_key) + }) + + issues = [ + { + 'type': 'issues', + 'id': issue['id'], + 'attributes': { + 'type': 'Github', + 'title': issue['title'], + 'url': settings.GITHUB_ISSUE_URL.format( + project.tracker_name, + issue['id'] + ), + 'state': issue['state'].capitalize() + } + } + for issue + in response.json() + ] + + return Response(issues) + + def get_remine_issues(self, project): + url = settings.REDMINE_API_URL.format(project.tracker_name) + + headers = { + 'X-Redmine-API-Key': project.tracker_api_key + } + + if (settings.REDMINE_BASIC_AUTH): + headers['Authorization'] = 'Basic {}'.format( + b64_encode('{}:{}'.format( + settings.REDMINE_BASIC_AUTH_USER, + settings.REDMINE_BASIC_AUTH_PASSWORD + )) + ) + + response = requests.get(url, headers={ + 'Authorization': 'Basic YWQtc3k6dnVzbGVnaW8=', + }) + + issues = [ + { + 'type': 'issues', + 'id': issue['id'], + 'attributes': { + 'type': 'Redmine', + 'title': issue['subject'], + 'url': settings.REDMINE_ISSUE_URL.format(issue['id']), + 'state': issue['status']['name'].capitalize() + } + } + for issue + in response.json()['issues'] + ] + + return Response(issues) + + def get(self, request, pk, format=None): + project = models.Project.objects.get(pk=pk) + + if project.tracker_type == 'GH': + return self.get_github_issues(project) + elif project.tracker_type == 'RM': + return self.get_remine_issues(project) + else: + return Response([]) From 273090ee8f094cd098a9cffcb6d22298a676fd07 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:37:33 +0200 Subject: [PATCH 002/980] Added travis config --- .travis.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..e360b2dd9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: python + +python: + - "3.5.1" + +services: + - postgresql + +cache: pip + +install: + - make install + +before_script: + - psql -c "CREATE ROLE timed LOGIN PASSWORD 'timed';" -U postgres + - psql -c "CREATE DATABASE test_timed;" -U postgres + +script: make test From 193e616660677630e2fc4b9653dc7f0dd729c693 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:39:00 +0200 Subject: [PATCH 003/980] Update README --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d707625f0..067165bc4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -# Timed (Backend) +# Timed Backend +[![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) +Timed timetracking software REST API built with Django + ## Installation **Requirements** * Python 3.5.1 @@ -19,4 +22,4 @@ $ ./manage.py createsuperuser # Create a new Django superuser You can now access the API at http://localhost:8000/api/v1 and the admin panel at http://localhost:8000/admin/ ## Testing -Run tests by executing `make test` \ No newline at end of file +Run tests by executing `make test` From 404214e2b06ebe133240216d6f24d5295b82d16c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:43:41 +0200 Subject: [PATCH 004/980] Removed ipdb from settings --- timed/settings.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/timed/settings.py b/timed/settings.py index 4033b9878..1795aa08d 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -204,9 +204,6 @@ def trueish(value): REDMINE_BASIC_AUTH_USER = redmine_config.get('REDMINE_BASIC_AUTH_USER') REDMINE_BASIC_AUTH_PASSWORD = redmine_config.get('REDMINE_BASIC_AUTH_PASSWORD') -import ipdb -ipdb.set_trace() - AUTH_LDAP_ALWAYS_UPDATE_USER = True AUTH_LDAP_USER_ATTR_MAP = { From 408de36156c4d99ceeb23a158847d3b14719e103 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:45:04 +0200 Subject: [PATCH 005/980] Use coveralls --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index e360b2dd9..487e5ed52 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,9 +10,12 @@ cache: pip install: - make install + - pip install coveralls before_script: - psql -c "CREATE ROLE timed LOGIN PASSWORD 'timed';" -U postgres - psql -c "CREATE DATABASE test_timed;" -U postgres script: make test + +after_success: coveralls From 399f74cd5c3b41afb755dcf0b5dfa7445b6d57c1 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:46:16 +0200 Subject: [PATCH 006/980] Fixed build --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index df8a0001e..df15481c6 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ install: pip install -e . + cp timed/config.sample.ini timed/config.ini test: test-lint test-coverage From dfe51e1f0e6a56934a60f92bf97cbf945940279a Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:49:41 +0200 Subject: [PATCH 007/980] Let timed db user own the test db --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 487e5ed52..43960c243 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ install: before_script: - psql -c "CREATE ROLE timed LOGIN PASSWORD 'timed';" -U postgres - - psql -c "CREATE DATABASE test_timed;" -U postgres + - psql -c "CREATE DATABASE test_timed OWNER timed;" -U postgres script: make test From 04a1b4ce46a84fc8cc179903b44593659cce95aa Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 12:55:27 +0200 Subject: [PATCH 008/980] TravisCI: Grant role timed the right to create a db --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 43960c243..d4e812573 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,7 @@ install: - pip install coveralls before_script: - - psql -c "CREATE ROLE timed LOGIN PASSWORD 'timed';" -U postgres - - psql -c "CREATE DATABASE test_timed OWNER timed;" -U postgres + - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres script: make test From 4bc7665f68ec355d8b7d49d3139c759970853e7a Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 13:33:51 +0200 Subject: [PATCH 009/980] Fixed wrong function name --- timed_api/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed_api/views.py b/timed_api/views.py index be37a798d..9fa56137b 100644 --- a/timed_api/views.py +++ b/timed_api/views.py @@ -6,7 +6,7 @@ from rest_framework.response import Response from django.contrib.auth.models import User from django.conf import settings -from base64 import b64_encode +from base64 import b64encode class UserViewSet(ReadOnlyModelViewSet): @@ -114,7 +114,7 @@ def get_remine_issues(self, project): if (settings.REDMINE_BASIC_AUTH): headers['Authorization'] = 'Basic {}'.format( - b64_encode('{}:{}'.format( + b64encode('{}:{}'.format( settings.REDMINE_BASIC_AUTH_USER, settings.REDMINE_BASIC_AUTH_PASSWORD )) From 3c79f734ddb642b0329a1b149a78248dfbd058dd Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 13:35:25 +0200 Subject: [PATCH 010/980] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 067165bc4..40ac06697 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -# Timed Backend -[![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) +# Timed Backend [![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) [![Coverage](https://coveralls.io/repos/github/adfinis-sygroup/timed-backend/badge.svg?branch=master)](https://coveralls.io/github/adfinis-sygroup/timed-backend?branch=master) Timed timetracking software REST API built with Django ## Installation From 942787a325880aef549e5ef5d91433dd9d394d15 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 14:01:30 +0200 Subject: [PATCH 011/980] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 40ac06697..8584ba4c0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Timed Backend [![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) [![Coverage](https://coveralls.io/repos/github/adfinis-sygroup/timed-backend/badge.svg?branch=master)](https://coveralls.io/github/adfinis-sygroup/timed-backend?branch=master) +# Timed Backend [![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) [![Coverage](https://img.shields.io/coveralls/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://coveralls.io/github/adfinis-sygroup/timed-backend) [![License](https://img.shields.io/github/license/adfinis-sygroup/timed-frontend.svg?style=flat-square)](https://github.com/adfinis-sygroup/timed-backend/blob/master/LICENSE) Timed timetracking software REST API built with Django ## Installation From 825f7bb9028b555e8703c005dac60d1d163128e0 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 16:55:21 +0200 Subject: [PATCH 012/980] Started testing --- timed/jsonapi_test_case.py | 52 ++++++++-- timed/urls.py | 4 +- timed_api/factories.py | 47 ++++++++- timed_api/tests/test_attendance.py | 141 ++++++++++++++++++++++++++ timed_api/tests/test_task_template.py | 97 ++++++++++++++++++ timed_api/tests/test_test.py | 7 -- timed_api/views.py | 5 +- 7 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 timed_api/tests/test_attendance.py create mode 100644 timed_api/tests/test_task_template.py delete mode 100644 timed_api/tests/test_test.py diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 6cbf32794..c04746ec1 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -1,4 +1,8 @@ -from rest_framework.test import APITestCase, APIClient +from rest_framework.test import APITestCase, APIClient +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from rest_framework import status +from rest_framework_jwt.settings import api_settings import json @@ -14,7 +18,7 @@ def _parse_data(self, data): return json.dumps(data) if data else data def get(self, path, data=None, **extra): - return super(JSONAPIClient, self).get( + return super().get( path=path, data=self._parse_data(data), content_type=self._content_type, @@ -22,7 +26,7 @@ def get(self, path, data=None, **extra): ) def post(self, path, data=None, **extra): - return super(JSONAPIClient, self).post( + return super().post( path=path, data=self._parse_data(data), content_type=self._content_type, @@ -30,7 +34,7 @@ def post(self, path, data=None, **extra): ) def delete(self, path, data=None, **extra): - return super(JSONAPIClient, self).delete( + return super().delete( path=path, data=self._parse_data(data), content_type=self._content_type, @@ -38,20 +42,56 @@ def delete(self, path, data=None, **extra): ) def patch(self, path, data=None, **extra): - return super(JSONAPIClient, self).patch( + return super().patch( path=path, data=self._parse_data(data), content_type=self._content_type, **extra ) + def login(self, username, password): + data = { + 'data': { + 'type': 'obtain-json-web-tokens', + 'id': None, + 'attributes': { + 'username': username, + 'password': password + } + } + } + + response = self.post(reverse('login'), data) + + if response.status_code == status.HTTP_200_OK: + self.credentials( + HTTP_AUTHORIZATION='{} {}'.format( + api_settings.JWT_AUTH_HEADER_PREFIX, + response.data['token'] + ) + ) + + return True + + return False + class JSONAPITestCase(APITestCase): def setUp(self): super(JSONAPITestCase, self).setUp() - self.client = JSONAPIClient() + self.user = User.objects.create_user( + username='tester', + password='123qweasd', + is_staff=True, + is_superuser=True + ) + + self.noauth_client = JSONAPIClient() + self.client = JSONAPIClient() + + self.client.login('tester', '123qweasd') def result(self, response): return json.loads(response.content.decode('utf8')) diff --git a/timed/urls.py b/timed/urls.py index 11a99bafb..fc5133ad3 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -4,7 +4,7 @@ urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^api/v1/auth/login', obtain_jwt_token), - url(r'^api/v1/auth/refresh', refresh_jwt_token), + url(r'^api/v1/auth/login', obtain_jwt_token, name='login'), + url(r'^api/v1/auth/refresh', refresh_jwt_token, name='refresh'), url(r'^api/v1/', include('timed_api.urls')) ] diff --git a/timed_api/factories.py b/timed_api/factories.py index 16de4fef0..5ba2e48fb 100644 --- a/timed_api/factories.py +++ b/timed_api/factories.py @@ -1,5 +1,46 @@ -# import factory +from factory import Faker, lazy_attribute +from factory.django import DjangoModelFactory +from timed_api import models +from random import randint +from pytz import timezone -# from timed_api import models +import datetime -# TODO +tzinfo = timezone('Europe/Zurich') + +today = datetime.date.today() + +begin_of_today = datetime.datetime( + today.year, + today.month, + today.day, + 0, 0, 0, + tzinfo=tzinfo +) + +end_of_today = begin_of_today + datetime.timedelta(days=1) + + +class AttendanceFactory(DjangoModelFactory): + from_datetime = Faker( + 'date_time_between_dates', + datetime_start=begin_of_today, + datetime_end=end_of_today, + tzinfo=tzinfo + ) + + @lazy_attribute + def to_datetime(self): + hours = randint(1, 5) + + return self.from_datetime + datetime.timedelta(hours=hours) + + class Meta: + model = models.Attendance + + +class TaskTemplateFactory(DjangoModelFactory): + name = Faker('sentence') + + class Meta: + model = models.TaskTemplate diff --git a/timed_api/tests/test_attendance.py b/timed_api/tests/test_attendance.py new file mode 100644 index 000000000..45dee8dc0 --- /dev/null +++ b/timed_api/tests/test_attendance.py @@ -0,0 +1,141 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import AttendanceFactory +from rest_framework import status +from django.contrib.auth.models import User +from datetime import timedelta + + +class AttendanceTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + other_user = User.objects.create_user( + username='tester2', + password='123qweasd' + ) + + self.attendances = AttendanceFactory.create_batch( + 10, + user=self.user + ) + + AttendanceFactory.create_batch( + 10, + user=other_user + ) + + def test_attendance_list(self): + response = self.client.get(reverse('attendance-list')) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(len(result['data']), len(self.attendances)) + + self.assertIn('id', result['data'][0]) + self.assertIn('from-datetime', result['data'][0]['attributes']) + self.assertIn('to-datetime', result['data'][0]['attributes']) + + def test_attendance_detail(self): + attendance = self.attendances[0] + + response = self.client.get(reverse('attendance-detail', args=[ + attendance.id + ])) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertIn('id', result['data']) + self.assertIn('from-datetime', result['data']['attributes']) + self.assertIn('to-datetime', result['data']['attributes']) + + def test_attendance_create(self): + attendance = AttendanceFactory.build() + + data = { + 'data': { + 'type': 'attendances', + 'id': None, + 'attributes': { + 'from-datetime': attendance.from_datetime.isoformat(), + 'to-datetime': attendance.from_datetime.isoformat() + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': self.user.id + } + } + } + } + } + + response = self.client.post(reverse('attendance-list'), data) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.assertIsNotNone(result['data']['id']) + + self.assertEqual( + result['data']['attributes']['from-datetime'], + data['data']['attributes']['from-datetime'] + ) + + self.assertEqual( + result['data']['attributes']['to-datetime'], + data['data']['attributes']['to-datetime'] + ) + + def test_attendance_update(self): + attendance = self.attendances[0] + + attendance.to_datetime += timedelta(hours=1) + + data = { + 'data': { + 'type': 'attendances', + 'id': attendance.id, + 'attributes': { + 'from-datetime': attendance.from_datetime.isoformat(), + 'to-datetime': attendance.to_datetime.isoformat() + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': attendance.user.id + } + } + } + } + } + + response = self.client.patch(reverse('attendance-detail', args=[ + attendance.id + ]), data) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual( + result['data']['attributes']['to-datetime'], + data['data']['attributes']['to-datetime'] + ) + + def test_attendance_delete(self): + attendance = self.attendances[0] + + response = self.client.delete(reverse('attendance-detail', args=[ + attendance.id + ])) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/timed_api/tests/test_task_template.py b/timed_api/tests/test_task_template.py new file mode 100644 index 000000000..7f7e40f50 --- /dev/null +++ b/timed_api/tests/test_task_template.py @@ -0,0 +1,97 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import TaskTemplateFactory +from rest_framework import status + + +class TaskTemplateTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + self.task_templates = TaskTemplateFactory.create_batch(5) + + def test_task_template_list(self): + response = self.client.get(reverse('task-template-list')) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(len(result['data']), len(self.task_templates)) + + self.assertIn('id', result['data'][0]) + self.assertIn('name', result['data'][0]['attributes']) + + def test_task_template_detail(self): + task_template = self.task_templates[0] + + response = self.client.get(reverse('task-template-detail', args=[ + task_template.id + ])) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertIn('id', result['data']) + self.assertIn('name', result['data']['attributes']) + + def test_task_template_create(self): + data = { + 'data': { + 'type': 'task-templates', + 'id': None, + 'attributes': { + 'name': 'Test Task Template' + } + } + } + + response = self.client.post(reverse('task-template-list'), data) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.assertIsNotNone(result['data']['id']) + + self.assertEqual( + result['data']['attributes']['name'], + data['data']['attributes']['name'] + ) + + def test_task_template_update(self): + task_template = self.task_templates[0] + + data = { + 'data': { + 'type': 'task-templates', + 'id': task_template.id, + 'attributes': { + 'name': 'Test Task Template' + } + } + } + + response = self.client.patch(reverse('task-template-detail', args=[ + task_template.id + ]), data) + + result = self.result(response) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual( + result['data']['attributes']['name'], + data['data']['attributes']['name'] + ) + + def test_task_template_delete(self): + task_template = self.task_templates[0] + + response = self.client.delete(reverse('task-template-detail', args=[ + task_template.id + ])) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/timed_api/tests/test_test.py b/timed_api/tests/test_test.py deleted file mode 100644 index cb7561341..000000000 --- a/timed_api/tests/test_test.py +++ /dev/null @@ -1,7 +0,0 @@ -from timed.jsonapi_test_case import JSONAPITestCase - - -class TestTests(JSONAPITestCase): - - def test_test(self): - self.assertTrue(True) diff --git a/timed_api/views.py b/timed_api/views.py index 9fa56137b..086b15648 100644 --- a/timed_api/views.py +++ b/timed_api/views.py @@ -38,9 +38,12 @@ def get_queryset(self): class AttendanceViewSet(ModelViewSet): queryset = models.Attendance.objects.all() - serializer_class = serializers.ActivitySerializer + serializer_class = serializers.AttendanceSerializer filter_class = filters.AttendanceFilterSet + def get_queryset(self): + return models.Attendance.objects.filter(user=self.request.user) + class ReportViewSet(ModelViewSet): queryset = models.Report.objects.all() From f484dceed60ed11ef655566ce046295612db3b04 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 16:58:23 +0200 Subject: [PATCH 013/980] Added missing requirement --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1447e4162..6cfe7f430 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,8 @@ "pytest-cov", "factory-boy==2.7.0", "psycopg2", - "ipdb" + "ipdb", + "pytz" ], author = "Adfinis SyGroup AG", author_email = "https://adfinis-sygroup.ch/", From e5257fb5739743dd13c0a6b612d289a0f1d8f416 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 17:04:47 +0200 Subject: [PATCH 014/980] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8584ba4c0..01c8ada55 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,6 @@ You can now access the API at http://localhost:8000/api/v1 and the admin panel a ## Testing Run tests by executing `make test` + +## License +Code released under the [GNU Affero General Public License](LICENSE). From acce9b2fe22f793ac366e910b263fb5aee16db1b Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 Aug 2016 17:06:33 +0200 Subject: [PATCH 015/980] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 01c8ada55..4e31f0e46 100644 --- a/README.md +++ b/README.md @@ -24,4 +24,4 @@ You can now access the API at http://localhost:8000/api/v1 and the admin panel a Run tests by executing `make test` ## License -Code released under the [GNU Affero General Public License](LICENSE). +Code released under the [GNU Affero General Public License v3.0](LICENSE). From 57d6d01cfabd0532485b6de054122611de005d27 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 15 Aug 2016 09:08:14 +0200 Subject: [PATCH 016/980] Improved django admin UI --- timed/settings.py | 4 ++-- timed_api/admin.py | 34 +++++++++++++++++++++++----------- timed_api/models.py | 16 ++++++++++++++-- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/timed/settings.py b/timed/settings.py index 1795aa08d..746895121 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -123,11 +123,11 @@ def trueish(value): # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ -LANGUAGE_CODE = 'de-CH' +LANGUAGE_CODE = 'en-US' TIME_ZONE = 'Europe/Zurich' -USE_I18N = True +USE_I18N = False USE_L10N = False DATETIME_FORMAT = 'd.m.Y H:i:s' diff --git a/timed_api/admin.py b/timed_api/admin.py index 20835d614..ae4394cc4 100644 --- a/timed_api/admin.py +++ b/timed_api/admin.py @@ -2,24 +2,24 @@ from timed_api import models -@admin.register(models.Activity) -class ActivityAdmin(admin.ModelAdmin): - list_display = [ 'task', 'comment', 'duration' ] +class ActivityBlockInline(admin.StackedInline): + model = models.ActivityBlock -@admin.register(models.ActivityBlock) -class ActivityBlockAdmin(admin.ModelAdmin): - list_display = [ 'from_datetime', 'to_datetime', 'duration' ] +@admin.register(models.Activity) +class ActivityAdmin(admin.ModelAdmin): + list_display = [ 'task', 'user', 'start_datetime', 'comment', 'duration' ] + inlines = (ActivityBlockInline,) @admin.register(models.Attendance) class AttendanceAdmin(admin.ModelAdmin): - list_display = [ 'from_datetime', 'to_datetime' ] + list_display = [ 'user', 'from_datetime', 'to_datetime' ] @admin.register(models.Report) class ReportAdmin(admin.ModelAdmin): - list_display = [ 'user', 'comment' ] + list_display = [ 'user', 'task', 'duration', 'comment' ] @admin.register(models.Customer) @@ -29,14 +29,26 @@ class CustomerAdmin(admin.ModelAdmin): @admin.register(models.Project) class ProjectAdmin(admin.ModelAdmin): - list_display = [ 'name' ] + list_display = [ 'customer', 'name' ] @admin.register(models.Task) class TaskAdmin(admin.ModelAdmin): - list_display = [ 'name' ] + list_display = [ 'get_customer', 'get_project', 'name' ] + + def get_customer(self, obj): + return obj.project.customer.name + + get_customer.short_description = 'Customer' + get_customer.admin_order_field = 'project__customer__name' + + def get_project(self, obj): + return obj.project.name + + get_project.short_description = 'Project' + get_project.admin_order_field = 'project__name' @admin.register(models.TaskTemplate) class TaskTemplateAdmin(admin.ModelAdmin): - list_display = [ 'name' ] + list_display = [ '__str__' ] diff --git a/timed_api/models.py b/timed_api/models.py index c9e2803e2..6d6e53b38 100644 --- a/timed_api/models.py +++ b/timed_api/models.py @@ -25,6 +25,12 @@ def duration(self): return sum(durations, timedelta()) + def __str__(self): + return '{}: {}'.format(self.user, self.task) + + class Meta: + verbose_name_plural = 'activities' + class ActivityBlock(models.Model): activity = models.ForeignKey('Activity', related_name='blocks') @@ -38,6 +44,9 @@ def duration(self): return self.to_datetime - self.from_datetime + def __str__(self): + return '{} ({})'.format(self.activity, self.duration) + class Attendance(models.Model): from_datetime = models.DateTimeField() @@ -59,6 +68,9 @@ class Report(models.Model): related_name='reports' ) + def __str__(self): + return '{}: {}'.format(self.user, self.task) + class Customer(models.Model): name = models.CharField(max_length=255) @@ -91,7 +103,7 @@ class Project(models.Model): ) def __str__(self): - return self.name + return '{} > {}'.format(self.customer, self.name) class Task(models.Model): @@ -101,7 +113,7 @@ class Task(models.Model): project = models.ForeignKey('Project', related_name='tasks') def __str__(self): - return self.name + return '{} > {}'.format(self.project, self.name) class TaskTemplate(models.Model): From 2c0620fb4fbe39f0678a76b1d701c85ca642a5cc Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 15 Aug 2016 10:59:30 +0200 Subject: [PATCH 017/980] Added some more tests --- timed/jsonapi_test_case.py | 26 +++- timed/settings.py | 3 +- timed_api/factories.py | 44 ++++++- timed_api/fixtures/groups.json | 56 +++++++++ timed_api/serializers.py | 2 + timed_api/tests/test_attendance.py | 54 +++++--- timed_api/tests/test_customer.py | 152 ++++++++++++++++++++++ timed_api/tests/test_project.py | 173 ++++++++++++++++++++++++++ timed_api/tests/test_task_template.py | 64 +++++++--- 9 files changed, 533 insertions(+), 41 deletions(-) create mode 100644 timed_api/fixtures/groups.json create mode 100644 timed_api/tests/test_customer.py create mode 100644 timed_api/tests/test_project.py diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index c04746ec1..4bfe814c6 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -1,5 +1,5 @@ from rest_framework.test import APITestCase, APIClient -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group from django.core.urlresolvers import reverse from rest_framework import status from rest_framework_jwt.settings import api_settings @@ -78,19 +78,35 @@ def login(self, username, password): class JSONAPITestCase(APITestCase): + fixtures = [ 'groups' ] + def setUp(self): super(JSONAPITestCase, self).setUp() + self.admin_user = User.objects.create_user( + username='admin', + password='123qweasd' + ) + + self.admin_user.groups.add( + Group.objects.get(name='Administrator') + ) + self.user = User.objects.create_user( username='tester', - password='123qweasd', - is_staff=True, - is_superuser=True + password='123qweasd' + ) + + self.user.groups.add( + Group.objects.get(name='User') ) self.noauth_client = JSONAPIClient() - self.client = JSONAPIClient() + self.admin_client = JSONAPIClient() + self.admin_client.login('admin', '123qweasd') + + self.client = JSONAPIClient() self.client.login('tester', '123qweasd') def result(self, response): diff --git a/timed/settings.py b/timed/settings.py index 746895121..a85481326 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -160,11 +160,12 @@ def trueish(value): ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', + 'rest_framework.permissions.DjangoModelPermissions', ), 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication', - 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', diff --git a/timed_api/factories.py b/timed_api/factories.py index 5ba2e48fb..4232ff0aa 100644 --- a/timed_api/factories.py +++ b/timed_api/factories.py @@ -1,4 +1,4 @@ -from factory import Faker, lazy_attribute +from factory import Faker, lazy_attribute, SubFactory from factory.django import DjangoModelFactory from timed_api import models from random import randint @@ -21,6 +21,17 @@ end_of_today = begin_of_today + datetime.timedelta(days=1) +class UserFactory(DjangoModelFactory): + first_name = Faker('first_name') + last_name = Faker('last_name') + email = Faker('email') + password = Faker('password', length=12) + + @lazy_attribute + def username(self): + return self.first_name.lower() + self.last_name[0].lower() + + class AttendanceFactory(DjangoModelFactory): from_datetime = Faker( 'date_time_between_dates', @@ -39,6 +50,37 @@ class Meta: model = models.Attendance +class CustomerFactory(DjangoModelFactory): + name = Faker('company') + email = Faker('company_email') + website = Faker('url') + comment = Faker('sentence') + archived = False + + class Meta: + model = models.Customer + + +class ProjectFactory(DjangoModelFactory): + name = Faker('catch_phrase') + archived = False + comment = Faker('sentence') + customer = SubFactory(CustomerFactory) + + class Meta: + model = models.Project + + +class TaskFactory(DjangoModelFactory): + name = Faker('company_suffix') + estimated_hours = Faker('random_int', min=0, max=2000) + archived = False + project = SubFactory(ProjectFactory) + + class Meta: + model = models.Task + + class TaskTemplateFactory(DjangoModelFactory): name = Faker('sentence') diff --git a/timed_api/fixtures/groups.json b/timed_api/fixtures/groups.json new file mode 100644 index 000000000..1b914854b --- /dev/null +++ b/timed_api/fixtures/groups.json @@ -0,0 +1,56 @@ +[ + { + "model": "auth.group", + "pk": 1, + "fields": { + "name": "Administrator", + "permissions": [ + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 31, + 32, + 33, + 34, + 35, + 36, + 28, + 29, + 30, + 37, + 38, + 39, + 40, + 41, + 42 + ] + } + }, + { + "model": "auth.group", + "pk": 2, + "fields": { + "name": "User", + "permissions": [ + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ] + } + } +] diff --git a/timed_api/serializers.py b/timed_api/serializers.py index b62ca15f9..b72fde8d6 100644 --- a/timed_api/serializers.py +++ b/timed_api/serializers.py @@ -88,6 +88,7 @@ class Meta: class CustomerSerializer(ModelSerializer): projects = ResourceRelatedField( queryset=models.Project.objects.all(), + required=False, many=True ) @@ -102,6 +103,7 @@ class ProjectSerializer(ModelSerializer): leaders = ResourceRelatedField( queryset=User.objects.all(), + required=False, many=True ) diff --git a/timed_api/tests/test_attendance.py b/timed_api/tests/test_attendance.py index 45dee8dc0..5f977eeb2 100644 --- a/timed_api/tests/test_attendance.py +++ b/timed_api/tests/test_attendance.py @@ -27,11 +27,15 @@ def setUp(self): ) def test_attendance_list(self): - response = self.client.get(reverse('attendance-list')) + url = reverse('attendance-list') - result = self.result(response) + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + + result = self.result(user_res) self.assertEqual(len(result['data']), len(self.attendances)) @@ -42,13 +46,17 @@ def test_attendance_list(self): def test_attendance_detail(self): attendance = self.attendances[0] - response = self.client.get(reverse('attendance-detail', args=[ + url = reverse('attendance-detail', args=[ attendance.id - ])) + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) - result = self.result(response) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(response.status_code, status.HTTP_200_OK) + result = self.result(user_res) self.assertIn('id', result['data']) self.assertIn('from-datetime', result['data']['attributes']) @@ -76,11 +84,15 @@ def test_attendance_create(self): } } - response = self.client.post(reverse('attendance-list'), data) + url = reverse('attendance-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) - result = self.result(response) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + result = self.result(user_res) self.assertIsNotNone(result['data']['id']) @@ -118,13 +130,17 @@ def test_attendance_update(self): } } - response = self.client.patch(reverse('attendance-detail', args=[ + url = reverse('attendance-detail', args=[ attendance.id - ]), data) + ]) - result = self.result(response) + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + + result = self.result(user_res) self.assertEqual( result['data']['attributes']['to-datetime'], @@ -134,8 +150,12 @@ def test_attendance_update(self): def test_attendance_delete(self): attendance = self.attendances[0] - response = self.client.delete(reverse('attendance-detail', args=[ + url = reverse('attendance-detail', args=[ attendance.id - ])) + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_204_NO_CONTENT) diff --git a/timed_api/tests/test_customer.py b/timed_api/tests/test_customer.py new file mode 100644 index 000000000..058178738 --- /dev/null +++ b/timed_api/tests/test_customer.py @@ -0,0 +1,152 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import CustomerFactory +from rest_framework import status + + +class CustomerTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + self.customers = CustomerFactory.create_batch(10) + + CustomerFactory.create_batch( + 10, + archived=True + ) + + def test_customer_list(self): + url = reverse('customer-list') + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertEqual(len(result['data']), len(self.customers)) + + self.assertIn('id', result['data'][0]) + self.assertIn('name', result['data'][0]['attributes']) + self.assertIn('email', result['data'][0]['attributes']) + self.assertIn('website', result['data'][0]['attributes']) + self.assertIn('comment', result['data'][0]['attributes']) + self.assertIn('projects', result['data'][0]['relationships']) + + def test_customer_detail(self): + customer = self.customers[0] + + url = reverse('customer-detail', args=[ + customer.id + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertIn('id', result['data']) + self.assertIn('name', result['data']['attributes']) + self.assertIn('email', result['data']['attributes']) + self.assertIn('website', result['data']['attributes']) + self.assertIn('comment', result['data']['attributes']) + self.assertIn('projects', result['data']['relationships']) + + def test_customer_create(self): + data = { + 'data': { + 'type': 'customers', + 'id': None, + 'attributes': { + 'name': 'Test customer', + 'email': 'foo@bar.ch' + } + } + } + + url = reverse('customer-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + admin_res = self.admin_client.post(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_201_CREATED) + + result = self.result(admin_res) + + self.assertIsNotNone(result['data']['id']) + + self.assertEqual( + result['data']['attributes']['name'], + data['data']['attributes']['name'] + ) + + self.assertEqual( + result['data']['attributes']['email'], + data['data']['attributes']['email'] + ) + + def test_customer_update(self): + customer = self.customers[0] + + data = { + 'data': { + 'type': 'customers', + 'id': customer.id, + 'attributes': { + 'name': 'Test customer', + 'email': 'foo@bar.ch' + } + } + } + + url = reverse('customer-detail', args=[ + customer.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + admin_res = self.admin_client.patch(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertEqual( + result['data']['attributes']['name'], + data['data']['attributes']['name'] + ) + + self.assertEqual( + result['data']['attributes']['email'], + data['data']['attributes']['email'] + ) + + def test_customer_delete(self): + customer = self.customers[0] + + url = reverse('customer-detail', args=[ + customer.id + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + admin_res = self.admin_client.delete(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_204_NO_CONTENT) diff --git a/timed_api/tests/test_project.py b/timed_api/tests/test_project.py new file mode 100644 index 000000000..cad9ba52a --- /dev/null +++ b/timed_api/tests/test_project.py @@ -0,0 +1,173 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import ProjectFactory +from rest_framework import status + + +class ProjectTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + self.projects = ProjectFactory.create_batch(10) + + ProjectFactory.create_batch( + 10, + archived=True + ) + + def test_project_list(self): + url = reverse('project-list') + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertEqual(len(result['data']), len(self.projects)) + + self.assertIn('id', result['data'][0]) + self.assertIn('name', result['data'][0]['attributes']) + self.assertIn('comment', result['data'][0]['attributes']) + self.assertIn('tracker-type', result['data'][0]['attributes']) + self.assertIn('tracker-name', result['data'][0]['attributes']) + self.assertIn('tracker-api-key', result['data'][0]['attributes']) + self.assertIn('customer', result['data'][0]['relationships']) + self.assertIn('leaders', result['data'][0]['relationships']) + + def test_project_detail(self): + project = self.projects[0] + + url = reverse('project-detail', args=[ + project.id + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertIn('id', result['data']) + self.assertIn('name', result['data']['attributes']) + self.assertIn('comment', result['data']['attributes']) + self.assertIn('tracker-type', result['data']['attributes']) + self.assertIn('tracker-name', result['data']['attributes']) + self.assertIn('tracker-api-key', result['data']['attributes']) + self.assertIn('customer', result['data']['relationships']) + self.assertIn('leaders', result['data']['relationships']) + + def test_project_create(self): + customer = self.projects[1].customer + + data = { + 'data': { + 'type': 'projects', + 'id': None, + 'attributes': { + 'name': 'Test Project' + }, + 'relationships': { + 'customer': { + 'data': { + 'type': 'customers', + 'id': str(customer.id) + } + } + } + } + } + + url = reverse('project-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + admin_res = self.admin_client.post(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_201_CREATED) + + result = self.result(admin_res) + + self.assertIsNotNone(result['data']['id']) + + self.assertEqual( + result['data']['attributes']['name'], + data['data']['attributes']['name'] + ) + + self.assertEqual( + result['data']['relationships']['customer']['data']['id'], + data['data']['relationships']['customer']['data']['id'] + ) + + def test_project_update(self): + project = self.projects[0] + customer = self.projects[1].customer + + data = { + 'data': { + 'type': 'projects', + 'id': project.id, + 'attributes': { + 'name': 'Test Project' + }, + 'relationships': { + 'customer': { + 'data': { + 'type': 'customers', + 'id': str(customer.id) + } + } + } + } + } + + url = reverse('project-detail', args=[ + project.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + admin_res = self.admin_client.patch(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertEqual( + result['data']['attributes']['name'], + data['data']['attributes']['name'] + ) + + self.assertEqual( + result['data']['relationships']['customer']['data']['id'], + data['data']['relationships']['customer']['data']['id'] + ) + + def test_project_delete(self): + project = self.projects[0] + + url = reverse('project-detail', args=[ + project.id + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + admin_res = self.admin_client.delete(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_204_NO_CONTENT) diff --git a/timed_api/tests/test_task_template.py b/timed_api/tests/test_task_template.py index 7f7e40f50..1c375726c 100644 --- a/timed_api/tests/test_task_template.py +++ b/timed_api/tests/test_task_template.py @@ -12,11 +12,17 @@ def setUp(self): self.task_templates = TaskTemplateFactory.create_batch(5) def test_task_template_list(self): - response = self.client.get(reverse('task-template-list')) + url = reverse('task-template-list') - result = self.result(response) + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) self.assertEqual(len(result['data']), len(self.task_templates)) @@ -26,13 +32,19 @@ def test_task_template_list(self): def test_task_template_detail(self): task_template = self.task_templates[0] - response = self.client.get(reverse('task-template-detail', args=[ + url = reverse('task-template-detail', args=[ task_template.id - ])) + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) - result = self.result(response) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - self.assertEqual(response.status_code, status.HTTP_200_OK) + result = self.result(admin_res) self.assertIn('id', result['data']) self.assertIn('name', result['data']['attributes']) @@ -48,11 +60,17 @@ def test_task_template_create(self): } } - response = self.client.post(reverse('task-template-list'), data) + url = reverse('task-template-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + admin_res = self.admin_client.post(url, data) - result = self.result(response) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + result = self.result(admin_res) self.assertIsNotNone(result['data']['id']) @@ -74,13 +92,19 @@ def test_task_template_update(self): } } - response = self.client.patch(reverse('task-template-detail', args=[ + url = reverse('task-template-detail', args=[ task_template.id - ]), data) + ]) - result = self.result(response) + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + admin_res = self.admin_client.patch(url, data) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) self.assertEqual( result['data']['attributes']['name'], @@ -90,8 +114,14 @@ def test_task_template_update(self): def test_task_template_delete(self): task_template = self.task_templates[0] - response = self.client.delete(reverse('task-template-detail', args=[ + url = reverse('task-template-detail', args=[ task_template.id - ])) + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + admin_res = self.admin_client.delete(url) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_204_NO_CONTENT) From f9cd0a98922cb330651f658f9d7d0cab94019a7e Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 15 Aug 2016 11:04:20 +0200 Subject: [PATCH 018/980] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e31f0e46..3fc42a952 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Timed Backend [![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) [![Coverage](https://img.shields.io/coveralls/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://coveralls.io/github/adfinis-sygroup/timed-backend) [![License](https://img.shields.io/github/license/adfinis-sygroup/timed-frontend.svg?style=flat-square)](https://github.com/adfinis-sygroup/timed-backend/blob/master/LICENSE) +# Timed Backend +[![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) +[![Coverage](https://img.shields.io/coveralls/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://coveralls.io/github/adfinis-sygroup/timed-backend) +[![License](https://img.shields.io/github/license/adfinis-sygroup/timed-frontend.svg?style=flat-square)](LICENSE) + Timed timetracking software REST API built with Django ## Installation From 9d4e55ebdd7b4f4506c1e6a7fa92547d00805f4c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 15 Aug 2016 11:29:35 +0200 Subject: [PATCH 019/980] Added user tests --- timed_api/factories.py | 19 +++++-- timed_api/serializers.py | 16 +++++- timed_api/tests/test_user.py | 106 +++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 timed_api/tests/test_user.py diff --git a/timed_api/factories.py b/timed_api/factories.py index 4232ff0aa..e9cf5324d 100644 --- a/timed_api/factories.py +++ b/timed_api/factories.py @@ -1,8 +1,9 @@ -from factory import Faker, lazy_attribute, SubFactory -from factory.django import DjangoModelFactory -from timed_api import models -from random import randint -from pytz import timezone +from factory import Faker, lazy_attribute, SubFactory +from factory.django import DjangoModelFactory +from timed_api import models +from django.contrib.auth.models import User +from random import randint +from pytz import timezone import datetime @@ -29,7 +30,13 @@ class UserFactory(DjangoModelFactory): @lazy_attribute def username(self): - return self.first_name.lower() + self.last_name[0].lower() + return '{}.{}'.format( + self.first_name, + self.last_name, + ).lower() + + class Meta: + model = User class AttendanceFactory(DjangoModelFactory): diff --git a/timed_api/serializers.py b/timed_api/serializers.py index b72fde8d6..c74448b80 100644 --- a/timed_api/serializers.py +++ b/timed_api/serializers.py @@ -7,12 +7,22 @@ class UserSerializer(ModelSerializer): projects = ResourceRelatedField( - queryset=models.Project.objects.all(), + read_only=True, many=True ) attendances = ResourceRelatedField( - queryset=models.Attendance.objects.all(), + read_only=True, + many=True + ) + + activities = ResourceRelatedField( + read_only=True, + many=True + ) + + reports = ResourceRelatedField( + read_only=True, many=True ) @@ -25,6 +35,8 @@ class Meta: 'email', 'projects', 'attendances', + 'activities', + 'reports', ] diff --git a/timed_api/tests/test_user.py b/timed_api/tests/test_user.py new file mode 100644 index 000000000..a97a1b71f --- /dev/null +++ b/timed_api/tests/test_user.py @@ -0,0 +1,106 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import UserFactory +from rest_framework import status + + +class UserTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + self.users = UserFactory.create_batch(10) + + def test_user_list(self): + url = reverse('user-list') + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertEqual(len(result['data']), len(self.users) + 2) + + self.assertIn('id', result['data'][0]) + self.assertIn('username', result['data'][0]['attributes']) + self.assertIn('first-name', result['data'][0]['attributes']) + self.assertIn('last-name', result['data'][0]['attributes']) + self.assertIn('projects', result['data'][0]['relationships']) + self.assertIn('attendances', result['data'][0]['relationships']) + self.assertIn('activities', result['data'][0]['relationships']) + self.assertIn('reports', result['data'][0]['relationships']) + + def test_user_detail(self): + user = self.users[0] + + url = reverse('user-detail', args=[ + user.id + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + admin_res = self.admin_client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + + result = self.result(admin_res) + + self.assertIn('id', result['data']) + self.assertIn('username', result['data']['attributes']) + self.assertIn('first-name', result['data']['attributes']) + self.assertIn('last-name', result['data']['attributes']) + self.assertIn('projects', result['data']['relationships']) + self.assertIn('attendances', result['data']['relationships']) + self.assertIn('activities', result['data']['relationships']) + self.assertIn('reports', result['data']['relationships']) + + def test_user_create(self): + data = {} + url = reverse('user-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + admin_res = self.admin_client.post(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_403_FORBIDDEN) + + def test_user_update(self): + user = self.users[1] + data = {} + + url = reverse('user-detail', args=[ + user.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + admin_res = self.admin_client.patch(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_403_FORBIDDEN) + + def test_user_delete(self): + user = self.users[1] + data = {} + + url = reverse('user-detail', args=[ + user.id + ]) + + noauth_res = self.noauth_client.delete(url, data) + user_res = self.client.delete(url, data) + admin_res = self.admin_client.delete(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(admin_res.status_code, status.HTTP_403_FORBIDDEN) From 66dc5462d08cd56a3acb266a14e78a48e448e926 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 15 Aug 2016 13:37:16 +0200 Subject: [PATCH 020/980] Added more tests --- .coveragerc | 11 +- timed/jsonapi_test_case.py | 4 + timed_api/factories.py | 58 ++++++++--- timed_api/tests/test_project.py | 10 +- timed_api/tests/test_report.py | 177 ++++++++++++++++++++++++++++++++ timed_api/views.py | 4 +- 6 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 timed_api/tests/test_report.py diff --git a/.coveragerc b/.coveragerc index b1f30cccb..5695cdabb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,12 @@ [run] -source=timed_api,timed +source=timed_api [report] -omit=*/migrations/*.py,*/tests/*.py +omit=*/migrations/*.py,*/apps.py,*/admin.py +exclude_lines = + pragma: no cover + def __str__ + def __unicode__ + def __repr__ + +show_missing = True diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 4bfe814c6..3247cb244 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -5,6 +5,10 @@ from rest_framework_jwt.settings import api_settings import json +import logging + +logging.getLogger('factory').setLevel(logging.WARN) +logging.getLogger('django_auth_ldap').setLevel(logging.WARN) class JSONAPIClient(APIClient): diff --git a/timed_api/factories.py b/timed_api/factories.py index e9cf5324d..56984c1b5 100644 --- a/timed_api/factories.py +++ b/timed_api/factories.py @@ -4,22 +4,27 @@ from django.contrib.auth.models import User from random import randint from pytz import timezone +from faker import Factory as FakerFactory import datetime tzinfo = timezone('Europe/Zurich') -today = datetime.date.today() +faker = FakerFactory.create() -begin_of_today = datetime.datetime( - today.year, - today.month, - today.day, - 0, 0, 0, - tzinfo=tzinfo -) -end_of_today = begin_of_today + datetime.timedelta(days=1) +def begin_of_day(day): + return datetime.datetime( + day.year, + day.month, + day.day, + 0, 0, 0, + tzinfo=tzinfo + ) + + +def end_of_day(day): + return begin_of_day(day) + datetime.timedelta(days=1) class UserFactory(DjangoModelFactory): @@ -40,12 +45,15 @@ class Meta: class AttendanceFactory(DjangoModelFactory): - from_datetime = Faker( - 'date_time_between_dates', - datetime_start=begin_of_today, - datetime_end=end_of_today, - tzinfo=tzinfo - ) + date = datetime.date.today() + + @lazy_attribute + def from_datetime(self): + return faker.date_time_between_dates( + datetime_start=begin_of_day(self.date), + datetime_end=end_of_day(self.date), + tzinfo=tzinfo + ) @lazy_attribute def to_datetime(self): @@ -54,7 +62,8 @@ def to_datetime(self): return self.from_datetime + datetime.timedelta(hours=hours) class Meta: - model = models.Attendance + model = models.Attendance + exclude = ( 'date', ) class CustomerFactory(DjangoModelFactory): @@ -88,6 +97,23 @@ class Meta: model = models.Task +class ReportFactory(DjangoModelFactory): + comment = Faker('sentence') + review = False + nta = False + task = SubFactory(TaskFactory) + + @lazy_attribute + def duration(self): + return datetime.timedelta( + hours=randint(0, 4), + minutes=randint(0, 59) + ) + + class Meta: + model = models.Report + + class TaskTemplateFactory(DjangoModelFactory): name = Faker('sentence') diff --git a/timed_api/tests/test_project.py b/timed_api/tests/test_project.py index cad9ba52a..49e362413 100644 --- a/timed_api/tests/test_project.py +++ b/timed_api/tests/test_project.py @@ -1,6 +1,7 @@ from timed.jsonapi_test_case import JSONAPITestCase from django.core.urlresolvers import reverse -from timed_api.factories import ProjectFactory +from timed_api.factories import ProjectFactory, TaskTemplateFactory +from timed_api.models import Task from rest_framework import status @@ -171,3 +172,10 @@ def test_project_delete(self): self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(admin_res.status_code, status.HTTP_204_NO_CONTENT) + + def test_project_default_tasks(self): + templates = TaskTemplateFactory.create_batch(5) + project = ProjectFactory.create() + tasks = Task.objects.filter(project=project) + + self.assertEqual(len(templates), len(tasks)) diff --git a/timed_api/tests/test_report.py b/timed_api/tests/test_report.py new file mode 100644 index 000000000..238ab240d --- /dev/null +++ b/timed_api/tests/test_report.py @@ -0,0 +1,177 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import ReportFactory, TaskFactory +from rest_framework import status +from django.contrib.auth.models import User + + +class ReportTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + other_user = User.objects.create_user( + username='tester2', + password='123qweasd' + ) + + self.reports = ReportFactory.create_batch(10, user=self.user) + + ReportFactory.create_batch(10, user=other_user) + + def test_report_list(self): + url = reverse('report-list') + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + + result = self.result(user_res) + + self.assertEqual(len(result['data']), len(self.reports)) + + self.assertIn('id', result['data'][0]) + self.assertIn('comment', result['data'][0]['attributes']) + self.assertIn('duration', result['data'][0]['attributes']) + self.assertIn('review', result['data'][0]['attributes']) + self.assertIn('nta', result['data'][0]['attributes']) + self.assertIn('task', result['data'][0]['relationships']) + self.assertIn('user', result['data'][0]['relationships']) + + def test_report_detail(self): + report = self.reports[0] + + url = reverse('report-detail', args=[ + report.id + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + + result = self.result(user_res) + + self.assertIn('id', result['data']) + self.assertIn('comment', result['data']['attributes']) + self.assertIn('duration', result['data']['attributes']) + self.assertIn('review', result['data']['attributes']) + self.assertIn('nta', result['data']['attributes']) + self.assertIn('task', result['data']['relationships']) + self.assertIn('user', result['data']['relationships']) + + def test_report_create(self): + task = TaskFactory.create() + + data = { + 'data': { + 'type': 'reports', + 'id': None, + 'attributes': { + 'comment': 'foo', + 'duration': '00:50:00' + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': self.user.id + } + }, + 'task': { + 'data': { + 'type': 'tasks', + 'id': task.id + } + }, + } + } + } + + url = reverse('report-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_201_CREATED) + + result = self.result(user_res) + + self.assertIsNotNone(result['data']['id']) + + self.assertEqual( + result['data']['attributes']['comment'], + data['data']['attributes']['comment'] + ) + + self.assertEqual( + result['data']['attributes']['duration'], + data['data']['attributes']['duration'] + ) + + def test_report_update(self): + report = self.reports[0] + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foo', + 'duration': '00:50:00' + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': report.user.id + } + }, + 'task': { + 'data': { + 'type': 'tasks', + 'id': report.task.id + } + }, + } + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_200_OK) + + result = self.result(user_res) + + self.assertEqual( + result['data']['attributes']['comment'], + data['data']['attributes']['comment'] + ) + + self.assertEqual( + result['data']['attributes']['duration'], + data['data']['attributes']['duration'] + ) + + def test_report_delete(self): + report = self.reports[0] + + url = reverse('report-detail', args=[ + report.id + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + + self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(user_res.status_code, status.HTTP_204_NO_CONTENT) diff --git a/timed_api/views.py b/timed_api/views.py index 086b15648..bb8f3a7cd 100644 --- a/timed_api/views.py +++ b/timed_api/views.py @@ -46,10 +46,12 @@ def get_queryset(self): class ReportViewSet(ModelViewSet): - queryset = models.Report.objects.all() serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet + def get_queryset(self): + return models.Report.objects.filter(user=self.request.user) + class CustomerViewSet(ModelViewSet): queryset = models.Customer.objects.filter(archived=False) From 3f663a1a88f5b4d372d4f6e6884100c0bed81647 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 15 Aug 2016 15:13:36 +0200 Subject: [PATCH 021/980] Some github interface fixes --- timed_api/tests/test_project.py | 10 +++++----- timed_api/urls.py | 6 +++++- timed_api/views.py | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/timed_api/tests/test_project.py b/timed_api/tests/test_project.py index 49e362413..f62d90b13 100644 --- a/timed_api/tests/test_project.py +++ b/timed_api/tests/test_project.py @@ -1,8 +1,8 @@ -from timed.jsonapi_test_case import JSONAPITestCase -from django.core.urlresolvers import reverse -from timed_api.factories import ProjectFactory, TaskTemplateFactory -from timed_api.models import Task -from rest_framework import status +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import ProjectFactory, TaskTemplateFactory +from timed_api.models import Task +from rest_framework import status class ProjectTests(JSONAPITestCase): diff --git a/timed_api/urls.py b/timed_api/urls.py index 9613d4ab3..2b5d105ff 100644 --- a/timed_api/urls.py +++ b/timed_api/urls.py @@ -16,7 +16,11 @@ r.register(r'task-templates', views.TaskTemplateViewSet, 'task-template') urlpatterns = [ - url(r'projects/(?P[0-9]+)/issues', views.ProjectIssuesView.as_view()) + url( + r'projects/(?P[0-9]+)/issues', + views.ProjectIssuesView.as_view(), + name='project-issue-list' + ) ] urlpatterns.extend(r.urls) diff --git a/timed_api/views.py b/timed_api/views.py index bb8f3a7cd..849e78647 100644 --- a/timed_api/views.py +++ b/timed_api/views.py @@ -2,8 +2,11 @@ from timed_api import serializers, models, filters from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from rest_framework.response import Response +from rest_framework.exceptions import AuthenticationFailed, ParseError +from rest_framework import status from django.contrib.auth.models import User from django.conf import settings from base64 import b64encode @@ -82,6 +85,7 @@ class TaskTemplateViewSet(ModelViewSet): class ProjectIssuesView(APIView): + permission_classes = (IsAuthenticated,) def get_github_issues(self, project): url = settings.GITHUB_API_URL.format(project.tracker_name) @@ -90,6 +94,12 @@ def get_github_issues(self, project): 'Authorization': 'token {}'.format(project.tracker_api_key) }) + if (response.status_code == status.HTTP_401_UNAUTHORIZED): + raise AuthenticationFailed + + elif (response.status_code != status.HTTP_200_OK): + raise ParseError + issues = [ { 'type': 'issues', @@ -102,6 +112,14 @@ def get_github_issues(self, project): issue['id'] ), 'state': issue['state'].capitalize() + }, + 'relationships': { + 'project': { + 'data': { + 'type': 'projects', + 'id': project.id + } + } } } for issue @@ -138,6 +156,14 @@ def get_remine_issues(self, project): 'title': issue['subject'], 'url': settings.REDMINE_ISSUE_URL.format(issue['id']), 'state': issue['status']['name'].capitalize() + }, + 'relationships': { + 'project': { + 'data': { + 'type': 'projects', + 'id': project.id + } + } } } for issue From a3324e5b8aefdba44d677039001aa70851ed8ee5 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 29 Aug 2016 17:28:02 +0200 Subject: [PATCH 022/980] Added some filters and removed useless required fields in serializers --- timed_api/filters.py | 27 +++++++++++++++------------ timed_api/serializers.py | 6 ++++++ timed_api/views.py | 3 +++ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/timed_api/filters.py b/timed_api/filters.py index f2af88e72..f71f1d6e1 100644 --- a/timed_api/filters.py +++ b/timed_api/filters.py @@ -17,11 +17,12 @@ def wrapper(self, qs, value): return wrapper -class TodayFilter(Filter): - @boolean_filter +class DayFilter(Filter): def filter(self, qs, value): + date = datetime.datetime.strptime(value, '%Y-%m-%d').date() + return qs.filter(**{ - '%s__date' % self.name: datetime.date.today() + '%s__date' % self.name: date }) @@ -38,42 +39,44 @@ class Meta: class ActivityFilterSet(FilterSet): active = ActivityActiveFilter() - today = TodayFilter(name='start_datetime') + day = DayFilter(name='start_datetime') class Meta: - model = models.Activity + model = models.Activity class ActivityBlockFilterSet(FilterSet): class Meta: - model = models.ActivityBlock + model = models.ActivityBlock class AttendanceFilterSet(FilterSet): + day = DayFilter(name='from_datetime') + class Meta: - model = models.Attendance + model = models.Attendance class ReportFilterSet(FilterSet): class Meta: - model = models.Report + model = models.Report class CustomerFilterSet(FilterSet): class Meta: - model = models.Customer + model = models.Customer class ProjectFilterSet(FilterSet): class Meta: - model = models.Project + model = models.Project class TaskFilterSet(FilterSet): class Meta: - model = models.Task + model = models.Task class TaskTemplateFilterSet(FilterSet): class Meta: - model = models.TaskTemplate + model = models.TaskTemplate diff --git a/timed_api/serializers.py b/timed_api/serializers.py index c74448b80..5ccc12084 100644 --- a/timed_api/serializers.py +++ b/timed_api/serializers.py @@ -80,6 +80,12 @@ class Meta: class AttendanceSerializer(ModelSerializer): + user = ResourceRelatedField( + queryset=User.objects.all(), + allow_null=True, + required=False + ) + class Meta: model = models.Attendance diff --git a/timed_api/views.py b/timed_api/views.py index 849e78647..61d97407a 100644 --- a/timed_api/views.py +++ b/timed_api/views.py @@ -47,6 +47,9 @@ class AttendanceViewSet(ModelViewSet): def get_queryset(self): return models.Attendance.objects.filter(user=self.request.user) + def perform_create(self, serializer): + serializer.save(user=self.request.user) + class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer From 4d7068d00c7c12e396818eee52c8a0b1f432e3d4 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Wed, 4 Jan 2017 14:34:56 +0100 Subject: [PATCH 023/980] * Updated dependencies * Added missing tests * Fixed deprecations * Fixed various bugs --- .coveragerc | 15 +- Makefile | 33 ++-- dev_requirements.txt | 7 + docker-compose.yml | 6 +- requirements.txt | 10 + setup.py | 58 ++---- timed/jsonapi_test_case.py | 108 +++++++++-- timed/settings.py | 4 +- timed/urls.py | 1 + timed_api/__init__.py | 1 + timed_api/admin.py | 40 ++-- timed_api/apps.py | 3 +- timed_api/factories.py | 22 +++ timed_api/filters.py | 35 ++-- timed_api/fixtures/groups.json | 56 ------ .../migrations/0002_auto_20170104_0932.py | 25 +++ timed_api/models.py | 7 +- timed_api/serializers.py | 78 ++++++-- timed_api/tests/test_activity.py | 183 ++++++++++++++++++ timed_api/tests/test_activity_block.py | 139 +++++++++++++ timed_api/tests/test_attendance.py | 75 +++---- timed_api/tests/test_customer.py | 111 ++++------- timed_api/tests/test_project.py | 116 +++++------ timed_api/tests/test_report.py | 97 ++++------ timed_api/tests/test_task.py | 161 +++++++++++++++ timed_api/tests/test_task_template.py | 92 ++++----- timed_api/tests/test_user.py | 86 ++++---- timed_api/urls.py | 15 +- timed_api/views.py | 112 +---------- 29 files changed, 1047 insertions(+), 649 deletions(-) create mode 100644 dev_requirements.txt create mode 100644 requirements.txt delete mode 100644 timed_api/fixtures/groups.json create mode 100644 timed_api/migrations/0002_auto_20170104_0932.py create mode 100644 timed_api/tests/test_activity.py create mode 100644 timed_api/tests/test_activity_block.py create mode 100644 timed_api/tests/test_task.py diff --git a/.coveragerc b/.coveragerc index 5695cdabb..e0a3820cf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,12 +1,23 @@ [run] -source=timed_api +source=. [report] -omit=*/migrations/*.py,*/apps.py,*/admin.py +fail_under=100 + exclude_lines = pragma: no cover + pragma: todo cover def __str__ def __unicode__ def __repr__ +omit= + */migrations/* + */apps.py + */admin.py + manage.py + timed/settings_*.py + timed/wsgi.py + setup.py + show_missing = True diff --git a/Makefile b/Makefile index df15481c6..d4d31a4a7 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,29 @@ -.PHONY: setup-ldap create-ldap-user +.PHONY: help install install-dev setup-ldap create-ldap-user start test +.DEFAULT_GOAL := help -install: - pip install -e . - cp timed/config.sample.ini timed/config.ini +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -test: test-lint test-coverage +install: ## Install production environment + @pip install --upgrade -r requirements.txt + @pip install --upgrade . -test-coverage: - py.test --cov +install-dev: ## Install development environment + @pip install --upgrade -r requirements.txt + @pip install --upgrade -r dev_requirements.txt + @pip install -e . -test-lint: - flake8 - -setup-ldap: +setup-ldap: ## Setup the LDAP container docker exec -it timedbackendsrc_ucs_1 /usr/lib/univention-system-setup/scripts/setup-join.sh docker exec -it timedbackendsrc_ucs_1 /usr/ucs/scripts/init.sh -create-ldap-user: +create-ldap-user: ## Create a new user in the LDAP docker exec -it timedbackendsrc_ucs_1 /usr/ucs/scripts/create-new-user.sh + +start: ## Start the development server + @docker-compose start + @python manage.py runserver + +test: ## Test the project + @flake8 + @pytest --cov --create-db diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 000000000..1b48605d0 --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,7 @@ +flake8 +coverage +pytest +pytest-django +pytest-cov +factory-boy +ipdb diff --git a/docker-compose.yml b/docker-compose.yml index 21799884b..edf28f9e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: db: image: postgres ports: - - "5432:5432" + - '5432:5432' environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed @@ -14,7 +14,7 @@ services: - ./docker/ucs/timed-ucs.profile:/var/cache/univention-system-setup/profile - ./docker/ucs/scripts:/usr/ucs/scripts ports: - - "389:389" - - "8080:80" + - '389:389' + - '8080:80' environment: - rootpwd=univention diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..c1db4dc05 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +django==1.10.4 +django-auth-ldap==1.2.8 +djangorestframework==3.5.3 +djangorestframework-jsonapi==2.1.1 +djangorestframework-jwt==1.9.0 +django-filter==1.0.1 +django-crispy-forms==1.6.1 +django-jet==1.0.4 +psycopg2 +pytz diff --git a/setup.py b/setup.py index 6cfe7f430..9c3a28a6b 100644 --- a/setup.py +++ b/setup.py @@ -1,51 +1,29 @@ -"""Setuptools package definition""" +'''Setuptools package definition''' from setuptools import setup import codecs -with codecs.open("README.md", "r", encoding="UTF-8") as f: +with codecs.open('README.md', 'r', encoding='UTF-8') as f: README_TEXT = f.read() setup( - name = "timed", - version = "0.0.0", - entry_points = { - "console_scripts": [ - ] - }, - install_requires = [ - "django==1.9", - "django-auth-ldap==1.2.8", - "djangorestframework==3.4.1", - "djangorestframework-jsonapi==2.0.1", - "djangorestframework-jwt==1.8.0", - "django-filter==0.13", - "django-crispy-forms==1.6.0", - "flake8", - "coverage", - "pytest", - "pytest-django", - "pytest-cov", - "factory-boy==2.7.0", - "psycopg2", - "ipdb", - "pytz" - ], - author = "Adfinis SyGroup AG", - author_email = "https://adfinis-sygroup.ch/", - description = "Timetracking software", + name = 'timed', + version = '0.0.0', + author = 'Adfinis SyGroup AG', + author_email = 'https://adfinis-sygroup.ch/', + description = 'Timetracking software', long_description = README_TEXT, - keywords = "timetracking", - url = "https://adfinis-sygroup.ch/", + keywords = 'timetracking', + url = 'https://adfinis-sygroup.ch/', classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: " - "GNU Affero General Public License v3", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.5.1", + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'License :: OSI Approved :: ' + 'GNU Affero General Public License v3', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.5', ] ) diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 3247cb244..7356e13df 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -1,5 +1,5 @@ from rest_framework.test import APITestCase, APIClient -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import User, Group, Permission from django.core.urlresolvers import reverse from rest_framework import status from rest_framework_jwt.settings import api_settings @@ -14,7 +14,7 @@ class JSONAPIClient(APIClient): def __init__(self, *args, **kwargs): - super(JSONAPIClient, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._content_type = 'application/vnd.api+json' @@ -77,41 +77,111 @@ def login(self, username, password): return True - return False + return False # pragma: no cover class JSONAPITestCase(APITestCase): - fixtures = [ 'groups' ] - - def setUp(self): - super(JSONAPITestCase, self).setUp() - - self.admin_user = User.objects.create_user( - username='admin', + def get_system_admin_group_permissions(self): + return Permission.objects.filter(codename__in=[ + 'add_tasktemplate', + 'change_tasktemplate', + 'delete_tasktemplate', + ]) + + def get_project_admin_group_permissions(self): + return Permission.objects.filter(codename__in=[ + 'add_customer', + 'change_customer', + 'delete_customer', + 'add_project', + 'change_project', + 'delete_project', + 'add_task', + 'change_task', + 'delete_task', + ]) + + def get_user_group_permissions(self): + return Permission.objects.filter(codename__in=[ + 'add_activity', + 'change_activity', + 'delete_activity', + 'add_activityblock', + 'change_activityblock', + 'delete_activityblock', + 'add_attendance', + 'change_attendance', + 'delete_attendance', + 'add_report', + 'change_report', + 'delete_report', + ]) + + def create_groups(self): + system_admin_group = Group.objects.create(name='System Admin') + project_admin_group = Group.objects.create(name='Project Admin') + user_group = Group.objects.create(name='User') + + system_admin_perms = self.get_system_admin_group_permissions() + project_admin_perms = self.get_project_admin_group_permissions() + user_perms = self.get_user_group_permissions() + + system_admin_group.permissions.add(*system_admin_perms) + project_admin_group.permissions.add(*project_admin_perms) + user_group.permissions.add(*user_perms) + + system_admin_group.save() + project_admin_group.save() + user_group.save() + + def create_users(self): + self.system_admin_user = User.objects.create_user( + username='system_admin', password='123qweasd' ) - self.admin_user.groups.add( - Group.objects.get(name='Administrator') + self.project_admin_user = User.objects.create_user( + username='project_admin', + password='123qweasd' ) self.user = User.objects.create_user( - username='tester', + username='user', password='123qweasd' ) - self.user.groups.add( - Group.objects.get(name='User') - ) + self.system_admin_user.groups.add(*Group.objects.filter(name__in=[ + 'System Admin', + 'Project Admin', + 'User' + ])) + + self.project_admin_user.groups.add(*Group.objects.filter(name__in=[ + 'Project Admin', + 'User' + ])) + + self.user.groups.add(*Group.objects.filter(name__in=[ + 'User' + ])) + + def setUp(self): + super().setUp() + + self.create_groups() + self.create_users() self.noauth_client = JSONAPIClient() - self.admin_client = JSONAPIClient() - self.admin_client.login('admin', '123qweasd') + self.system_admin_client = JSONAPIClient() + self.system_admin_client.login('system_admin', '123qweasd') + + self.project_admin_client = JSONAPIClient() + self.project_admin_client.login('project_admin', '123qweasd') self.client = JSONAPIClient() - self.client.login('tester', '123qweasd') + self.client.login('user', '123qweasd') def result(self, response): return json.loads(response.content.decode('utf8')) diff --git a/timed/settings.py b/timed/settings.py index a85481326..b2e8c92af 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -28,6 +28,7 @@ def trueish(value): return value.lower() in ( 'true', '1', 'yes' ) + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ @@ -43,6 +44,7 @@ def trueish(value): # Application definition INSTALLED_APPS = [ + 'jet', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -144,9 +146,7 @@ def trueish(value): STATIC_URL = '/static/' REST_FRAMEWORK = { - 'ORDERING_PARAM': 'sort', 'PAGINATE_BY': None, - 'PAGINATE_BY_PARAM': 'page_size', 'MAX_PAGINATE_BY': 100, 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework.filters.DjangoFilterBackend', diff --git a/timed/urls.py b/timed/urls.py index fc5133ad3..c9d7edefb 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -3,6 +3,7 @@ from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token urlpatterns = [ + url(r'^jet/', include('jet.urls', 'jet')), url(r'^admin/', admin.site.urls), url(r'^api/v1/auth/login', obtain_jwt_token, name='login'), url(r'^api/v1/auth/refresh', refresh_jwt_token, name='refresh'), diff --git a/timed_api/__init__.py b/timed_api/__init__.py index e69de29bb..8b69e490c 100644 --- a/timed_api/__init__.py +++ b/timed_api/__init__.py @@ -0,0 +1 @@ +default_app_config = 'timed_api.apps.TimedAPIConfig' diff --git a/timed_api/admin.py b/timed_api/admin.py index ae4394cc4..979291d65 100644 --- a/timed_api/admin.py +++ b/timed_api/admin.py @@ -2,34 +2,52 @@ from timed_api import models -class ActivityBlockInline(admin.StackedInline): - model = models.ActivityBlock +class OwnerAdminMixin(object): + owner_field = 'user' + + def get_queryset(self, request): + qs = super().get_queryset(request) + + if request.user.is_superuser: + return qs + + return qs.filter(**{ self.owner_field: request.user }) + + +class ActivityBlockInline(OwnerAdminMixin, admin.StackedInline): + model = models.ActivityBlock + owner_field = 'activity__user' @admin.register(models.Activity) -class ActivityAdmin(admin.ModelAdmin): - list_display = [ 'task', 'user', 'start_datetime', 'comment', 'duration' ] +class ActivityAdmin(OwnerAdminMixin, admin.ModelAdmin): + list_display = ['comment', 'task', 'user', 'duration'] + list_filter = ['user', 'task', 'task__project', 'task__project__customer'] inlines = (ActivityBlockInline,) @admin.register(models.Attendance) -class AttendanceAdmin(admin.ModelAdmin): - list_display = [ 'user', 'from_datetime', 'to_datetime' ] +class AttendanceAdmin(OwnerAdminMixin, admin.ModelAdmin): + list_display = ['user', 'from_datetime', 'to_datetime'] + list_filter = ['user'] @admin.register(models.Report) -class ReportAdmin(admin.ModelAdmin): - list_display = [ 'user', 'task', 'duration', 'comment' ] +class ReportAdmin(OwnerAdminMixin, admin.ModelAdmin): + list_display = ['user', 'task', 'duration', 'comment'] @admin.register(models.Customer) class CustomerAdmin(admin.ModelAdmin): - list_display = [ 'name', 'email', 'website' ] + list_display = ['name'] + search_fields = ['name'] @admin.register(models.Project) class ProjectAdmin(admin.ModelAdmin): - list_display = [ 'customer', 'name' ] + list_display = ['name', 'customer'] + list_filter = ['customer'] + search_fields = ['name', 'customer'] @admin.register(models.Task) @@ -51,4 +69,4 @@ def get_project(self, obj): @admin.register(models.TaskTemplate) class TaskTemplateAdmin(admin.ModelAdmin): - list_display = [ '__str__' ] + list_display = ['name'] diff --git a/timed_api/apps.py b/timed_api/apps.py index 07bbc6d93..8c544d3ea 100644 --- a/timed_api/apps.py +++ b/timed_api/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig -class TimedApiConfig(AppConfig): +class TimedAPIConfig(AppConfig): name = 'timed_api' + verbose_name = 'API' diff --git a/timed_api/factories.py b/timed_api/factories.py index 56984c1b5..c0574d0dd 100644 --- a/timed_api/factories.py +++ b/timed_api/factories.py @@ -119,3 +119,25 @@ class TaskTemplateFactory(DjangoModelFactory): class Meta: model = models.TaskTemplate + + +class ActivityFactory(DjangoModelFactory): + comment = Faker('sentence') + task = SubFactory(TaskFactory) + + class Meta: + model = models.Activity + + +class ActivityBlockFactory(DjangoModelFactory): + activity = SubFactory(ActivityFactory) + from_datetime = Faker('date_time', tzinfo=tzinfo) + + @lazy_attribute + def to_datetime(self): + hours = randint(1, 5) + + return self.from_datetime + datetime.timedelta(hours=hours) + + class Meta: + model = models.ActivityBlock diff --git a/timed_api/filters.py b/timed_api/filters.py index f71f1d6e1..7c3fcdb84 100644 --- a/timed_api/filters.py +++ b/timed_api/filters.py @@ -9,8 +9,7 @@ def boolean_filter(func): @wraps(func) def wrapper(self, qs, value): - if value.lower() not in ( '1', 'true', 'yes' ): - return qs + value = value.lower() not in ( '1', 'true', 'yes' ) return func(self, qs, value) @@ -29,12 +28,16 @@ def filter(self, qs, value): class ActivityActiveFilter(Filter): @boolean_filter def filter(self, qs, value): - return qs.filter(blocks__to_datetime__exact=None) + return qs.filter( + blocks__isnull=False, + blocks__to_datetime__exact=None + ).distinct() class UserFilterSet(FilterSet): class Meta: - model = User + model = User + fields = [] class ActivityFilterSet(FilterSet): @@ -42,41 +45,49 @@ class ActivityFilterSet(FilterSet): day = DayFilter(name='start_datetime') class Meta: - model = models.Activity + model = models.Activity + fields = ['active', 'day'] class ActivityBlockFilterSet(FilterSet): class Meta: - model = models.ActivityBlock + model = models.ActivityBlock + fields = ['activity'] class AttendanceFilterSet(FilterSet): day = DayFilter(name='from_datetime') class Meta: - model = models.Attendance + model = models.Attendance + fields = ['day', 'user'] class ReportFilterSet(FilterSet): class Meta: - model = models.Report + model = models.Report + fields = ['user'] class CustomerFilterSet(FilterSet): class Meta: - model = models.Customer + model = models.Customer + fields = ['archived'] class ProjectFilterSet(FilterSet): class Meta: - model = models.Project + model = models.Project + fields = ['archived', 'customer'] class TaskFilterSet(FilterSet): class Meta: - model = models.Task + model = models.Task + fields = ['archived', 'project'] class TaskTemplateFilterSet(FilterSet): class Meta: - model = models.TaskTemplate + model = models.TaskTemplate + fields = [] diff --git a/timed_api/fixtures/groups.json b/timed_api/fixtures/groups.json deleted file mode 100644 index 1b914854b..000000000 --- a/timed_api/fixtures/groups.json +++ /dev/null @@ -1,56 +0,0 @@ -[ - { - "model": "auth.group", - "pk": 1, - "fields": { - "name": "Administrator", - "permissions": [ - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 31, - 32, - 33, - 34, - 35, - 36, - 28, - 29, - 30, - 37, - 38, - 39, - 40, - 41, - 42 - ] - } - }, - { - "model": "auth.group", - "pk": 2, - "fields": { - "name": "User", - "permissions": [ - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30 - ] - } - } -] diff --git a/timed_api/migrations/0002_auto_20170104_0932.py b/timed_api/migrations/0002_auto_20170104_0932.py new file mode 100644 index 000000000..cc8593995 --- /dev/null +++ b/timed_api/migrations/0002_auto_20170104_0932.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-01-04 08:32 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('timed_api', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='activity', + options={'verbose_name_plural': 'activities'}, + ), + migrations.AlterField( + model_name='report', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='timed_api.Task'), + ), + ] diff --git a/timed_api/models.py b/timed_api/models.py index 6d6e53b38..1cd786c69 100644 --- a/timed_api/models.py +++ b/timed_api/models.py @@ -62,7 +62,12 @@ class Report(models.Model): duration = models.DurationField() review = models.BooleanField(default=False) nta = models.BooleanField(default=False) - task = models.ForeignKey('Task', related_name='reports') + task = models.ForeignKey( + 'Task', + null=True, + blank=True, + related_name='reports' + ) user = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='reports' diff --git a/timed_api/serializers.py b/timed_api/serializers.py index 5ccc12084..7aba25ed2 100644 --- a/timed_api/serializers.py +++ b/timed_api/serializers.py @@ -48,9 +48,7 @@ class ActivitySerializer(ModelSerializer): ) user = ResourceRelatedField( - queryset=User.objects.all(), - allow_null=True, - required=False + read_only=True ) blocks = ResourceRelatedField( @@ -71,47 +69,75 @@ class Meta: class ActivityBlockSerializer(ModelSerializer): + duration = serializers.DurationField(read_only=True) + activity = ResourceRelatedField( queryset=models.Activity.objects.all() ) class Meta: - model = models.ActivityBlock + model = models.ActivityBlock + fields = [ + 'activity', + 'duration', + 'from_datetime', + 'to_datetime', + ] class AttendanceSerializer(ModelSerializer): user = ResourceRelatedField( - queryset=User.objects.all(), - allow_null=True, - required=False + read_only=True ) class Meta: - model = models.Attendance + model = models.Attendance + fields = [ + 'from_datetime', + 'to_datetime', + 'user', + ] class ReportSerializer(ModelSerializer): task = ResourceRelatedField( - queryset=models.Task.objects.all() + queryset=models.Task.objects.all(), + allow_null=True, + required=False ) user = ResourceRelatedField( - queryset=User.objects.all() + read_only=True ) class Meta: - model = models.Report + model = models.Report + fields = [ + 'comment', + 'duration', + 'review', + 'nta', + 'task', + 'user', + ] class CustomerSerializer(ModelSerializer): projects = ResourceRelatedField( - queryset=models.Project.objects.all(), - required=False, + read_only=True, many=True ) class Meta: model = models.Customer + fields = [ + 'name', + 'email', + 'website', + 'comment', + 'archived', + 'projects', + ] class ProjectSerializer(ModelSerializer): @@ -126,18 +152,28 @@ class ProjectSerializer(ModelSerializer): ) tasks = ResourceRelatedField( - queryset=models.Task.objects.all(), - required=False, + read_only=True, many=True ) class Meta: model = models.Project + fields = [ + 'name', + 'comment', + 'archived', + 'tracker_type', + 'tracker_name', + 'tracker_api_key', + 'customer', + 'leaders', + 'tasks', + ] class TaskSerializer(ModelSerializer): activities = ResourceRelatedField( - queryset=models.Activity.objects.all(), + read_only=True, many=True ) @@ -147,11 +183,21 @@ class TaskSerializer(ModelSerializer): class Meta: model = models.Task + fields = [ + 'name', + 'estimated_hours', + 'archived', + 'project', + 'activities', + ] class TaskTemplateSerializer(ModelSerializer): class Meta: model = models.TaskTemplate + fields = [ + 'name', + ] UserSerializer.included_serializers = { diff --git a/timed_api/tests/test_activity.py b/timed_api/tests/test_activity.py new file mode 100644 index 000000000..be15f9807 --- /dev/null +++ b/timed_api/tests/test_activity.py @@ -0,0 +1,183 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import ActivityFactory, ActivityBlockFactory +from django.contrib.auth.models import User +from datetime import datetime +from pytz import timezone + +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED +) + + +class ActivityTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + other_user = User.objects.create_user( + username='test', + password='123qweasd' + ) + + self.activities = ActivityFactory.create_batch( + 10, + user=self.user + ) + + for activity in self.activities: + ActivityBlockFactory.create_batch(5, activity=activity) + + ActivityFactory.create_batch( + 10, + user=other_user + ) + + def test_activity_list(self): + url = reverse('activity-list') + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + result = self.result(user_res) + + assert len(result['data']) == len(self.activities) + + def test_activity_detail(self): + activity = self.activities[0] + + url = reverse('activity-detail', args=[ + activity.id + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + def test_activity_create(self): + task = self.activities[0].task + + data = { + 'data': { + 'type': 'activities', + 'id': None, + 'attributes': { + 'comment': 'Test activity' + }, + 'relationships': { + 'task': { + 'data': { + 'type': 'tasks', + 'id': task.id + } + } + } + } + } + + url = reverse('activity-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_201_CREATED + + result = self.result(user_res) + + assert ( + int(result['data']['relationships']['user']['data']['id']) == + int(self.user.id) + ) + + def test_activity_update(self): + activity = self.activities[0] + + data = { + 'data': { + 'type': 'activities', + 'id': activity.id, + 'attributes': { + 'comment': 'Test activity 2' + } + } + } + + url = reverse('activity-detail', args=[ + activity.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + result = self.result(user_res) + + assert ( + result['data']['attributes']['comment'] == + data['data']['attributes']['comment'] + ) + + def test_activity_delete(self): + activity = self.activities[0] + + url = reverse('activity-detail', args=[ + activity.id + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_204_NO_CONTENT + + def test_activity_list_filter_active(self): + activity = self.activities[0] + block = ActivityBlockFactory.create(activity=activity) + + block.to_datetime = None + block.save() + + url = reverse('activity-list') + + res = self.client.get('{}?active=true'.format(url)) + + result = self.result(res) + + assert len(result['data']) == 1 + + assert int(result['data'][0]['id']) == int(activity.id) + + def test_activity_list_filter_day(self): + now = datetime.now(timezone('Europe/Zurich')) + activity = self.activities[0] + + activity.start_datetime = now + activity.save() + + url = reverse('activity-list') + + res = self.client.get('{}?day={}'.format( + url, + now.strftime('%Y-%m-%d') + )) + + result = self.result(res) + + assert len(result['data']) >= 1 + + assert any([ + int(data['id']) == activity.id + for data + in result['data'] + ]) diff --git a/timed_api/tests/test_activity_block.py b/timed_api/tests/test_activity_block.py new file mode 100644 index 000000000..3830c6618 --- /dev/null +++ b/timed_api/tests/test_activity_block.py @@ -0,0 +1,139 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import ActivityFactory, ActivityBlockFactory +from django.contrib.auth.models import User +from datetime import datetime +from pytz import timezone + +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED +) + + +class ActivityBlockTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + other_user = User.objects.create_user( + username='test', + password='123qweasd' + ) + + activity = ActivityFactory.create(user=self.user) + other_activity = ActivityFactory.create(user=other_user) + + self.activity_blocks = ActivityBlockFactory.create_batch( + 10, + activity=activity + ) + + ActivityBlockFactory.create_batch( + 10, + activity=other_activity + ) + + def test_activity_block_list(self): + url = reverse('activity-block-list') + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + result = self.result(user_res) + + assert len(result['data']) == len(self.activity_blocks) + + def test_activity_block_detail(self): + activity_block = self.activity_blocks[0] + + url = reverse('activity-block-detail', args=[ + activity_block.id + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + def test_activity_block_create(self): + activity = self.activity_blocks[0].activity + + data = { + 'data': { + 'type': 'activity-blocks', + 'id': None, + 'attributes': {}, + 'relationships': { + 'activity': { + 'data': { + 'type': 'activities', + 'id': activity.id + } + } + } + } + } + + url = reverse('activity-block-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_201_CREATED + + result = self.result(user_res) + + assert not result['data']['attributes']['from-datetime'] is None + assert result['data']['attributes']['to-datetime'] is None + + def test_activity_block_update(self): + activity_block = self.activity_blocks[0] + tz = timezone('Europe/Zurich') + + data = { + 'data': { + 'type': 'activity-blocks', + 'id': activity_block.id, + 'attributes': { + 'to-datetime': datetime.now(tz).isoformat() + } + } + } + + url = reverse('activity-block-detail', args=[ + activity_block.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + result = self.result(user_res) + + assert ( + result['data']['attributes']['to-datetime'] == + data['data']['attributes']['to-datetime'] + ) + + def test_activity_delete(self): + activity_block = self.activity_blocks[0] + + url = reverse('activity-block-detail', args=[ + activity_block.id + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_204_NO_CONTENT diff --git a/timed_api/tests/test_attendance.py b/timed_api/tests/test_attendance.py index 5f977eeb2..19e0cc301 100644 --- a/timed_api/tests/test_attendance.py +++ b/timed_api/tests/test_attendance.py @@ -1,10 +1,16 @@ from timed.jsonapi_test_case import JSONAPITestCase from django.core.urlresolvers import reverse from timed_api.factories import AttendanceFactory -from rest_framework import status from django.contrib.auth.models import User from datetime import timedelta +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED +) + class AttendanceTests(JSONAPITestCase): @@ -12,7 +18,7 @@ def setUp(self): super().setUp() other_user = User.objects.create_user( - username='tester2', + username='test', password='123qweasd' ) @@ -32,16 +38,12 @@ def test_attendance_list(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK result = self.result(user_res) - self.assertEqual(len(result['data']), len(self.attendances)) - - self.assertIn('id', result['data'][0]) - self.assertIn('from-datetime', result['data'][0]['attributes']) - self.assertIn('to-datetime', result['data'][0]['attributes']) + assert len(result['data']) == len(self.attendances) def test_attendance_detail(self): attendance = self.attendances[0] @@ -53,14 +55,8 @@ def test_attendance_detail(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - - result = self.result(user_res) - - self.assertIn('id', result['data']) - self.assertIn('from-datetime', result['data']['attributes']) - self.assertIn('to-datetime', result['data']['attributes']) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK def test_attendance_create(self): attendance = AttendanceFactory.build() @@ -72,14 +68,6 @@ def test_attendance_create(self): 'attributes': { 'from-datetime': attendance.from_datetime.isoformat(), 'to-datetime': attendance.from_datetime.isoformat() - }, - 'relationships': { - 'user': { - 'data': { - 'type': 'users', - 'id': self.user.id - } - } } } } @@ -89,21 +77,16 @@ def test_attendance_create(self): noauth_res = self.noauth_client.post(url, data) user_res = self.client.post(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_201_CREATED) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_201_CREATED result = self.result(user_res) - self.assertIsNotNone(result['data']['id']) - - self.assertEqual( - result['data']['attributes']['from-datetime'], - data['data']['attributes']['from-datetime'] - ) + assert not result['data']['id'] is None - self.assertEqual( - result['data']['attributes']['to-datetime'], - data['data']['attributes']['to-datetime'] + assert ( + int(result['data']['relationships']['user']['data']['id']) == + int(self.user.id) ) def test_attendance_update(self): @@ -118,14 +101,6 @@ def test_attendance_update(self): 'attributes': { 'from-datetime': attendance.from_datetime.isoformat(), 'to-datetime': attendance.to_datetime.isoformat() - }, - 'relationships': { - 'user': { - 'data': { - 'type': 'users', - 'id': attendance.user.id - } - } } } } @@ -137,13 +112,13 @@ def test_attendance_update(self): noauth_res = self.noauth_client.patch(url, data) user_res = self.client.patch(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK result = self.result(user_res) - self.assertEqual( - result['data']['attributes']['to-datetime'], + assert ( + result['data']['attributes']['to-datetime'] == data['data']['attributes']['to-datetime'] ) @@ -157,5 +132,5 @@ def test_attendance_delete(self): noauth_res = self.noauth_client.delete(url) user_res = self.client.delete(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_204_NO_CONTENT) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_204_NO_CONTENT diff --git a/timed_api/tests/test_customer.py b/timed_api/tests/test_customer.py index 058178738..07bba2d43 100644 --- a/timed_api/tests/test_customer.py +++ b/timed_api/tests/test_customer.py @@ -1,7 +1,14 @@ -from timed.jsonapi_test_case import JSONAPITestCase -from django.core.urlresolvers import reverse -from timed_api.factories import CustomerFactory -from rest_framework import status +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import CustomerFactory + +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN +) class CustomerTests(JSONAPITestCase): @@ -21,22 +28,13 @@ def test_customer_list(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - result = self.result(admin_res) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK - self.assertEqual(len(result['data']), len(self.customers)) + result = self.result(user_res) - self.assertIn('id', result['data'][0]) - self.assertIn('name', result['data'][0]['attributes']) - self.assertIn('email', result['data'][0]['attributes']) - self.assertIn('website', result['data'][0]['attributes']) - self.assertIn('comment', result['data'][0]['attributes']) - self.assertIn('projects', result['data'][0]['relationships']) + assert len(result['data']) == len(self.customers) def test_customer_detail(self): customer = self.customers[0] @@ -47,20 +45,9 @@ def test_customer_detail(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - - result = self.result(admin_res) - self.assertIn('id', result['data']) - self.assertIn('name', result['data']['attributes']) - self.assertIn('email', result['data']['attributes']) - self.assertIn('website', result['data']['attributes']) - self.assertIn('comment', result['data']['attributes']) - self.assertIn('projects', result['data']['relationships']) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK def test_customer_create(self): data = { @@ -76,27 +63,13 @@ def test_customer_create(self): url = reverse('customer-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - admin_res = self.admin_client.post(url, data) + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + project_admin_res = self.project_admin_client.post(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_201_CREATED) - - result = self.result(admin_res) - - self.assertIsNotNone(result['data']['id']) - - self.assertEqual( - result['data']['attributes']['name'], - data['data']['attributes']['name'] - ) - - self.assertEqual( - result['data']['attributes']['email'], - data['data']['attributes']['email'] - ) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_201_CREATED def test_customer_update(self): customer = self.customers[0] @@ -106,8 +79,7 @@ def test_customer_update(self): 'type': 'customers', 'id': customer.id, 'attributes': { - 'name': 'Test customer', - 'email': 'foo@bar.ch' + 'name': 'Test customer 2' } } } @@ -116,26 +88,21 @@ def test_customer_update(self): customer.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - admin_res = self.admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + project_admin_res = self.project_admin_client.patch(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_200_OK - result = self.result(admin_res) + result = self.result(project_admin_res) - self.assertEqual( - result['data']['attributes']['name'], + assert ( + result['data']['attributes']['name'] == data['data']['attributes']['name'] ) - self.assertEqual( - result['data']['attributes']['email'], - data['data']['attributes']['email'] - ) - def test_customer_delete(self): customer = self.customers[0] @@ -143,10 +110,10 @@ def test_customer_delete(self): customer.id ]) - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) - admin_res = self.admin_client.delete(url) + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + project_admin_res = self.project_admin_client.delete(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_204_NO_CONTENT) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_204_NO_CONTENT diff --git a/timed_api/tests/test_project.py b/timed_api/tests/test_project.py index f62d90b13..95def83b3 100644 --- a/timed_api/tests/test_project.py +++ b/timed_api/tests/test_project.py @@ -2,7 +2,14 @@ from django.core.urlresolvers import reverse from timed_api.factories import ProjectFactory, TaskTemplateFactory from timed_api.models import Task -from rest_framework import status + +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN +) class ProjectTests(JSONAPITestCase): @@ -22,24 +29,13 @@ def test_project_list(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - result = self.result(admin_res) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK - self.assertEqual(len(result['data']), len(self.projects)) + result = self.result(user_res) - self.assertIn('id', result['data'][0]) - self.assertIn('name', result['data'][0]['attributes']) - self.assertIn('comment', result['data'][0]['attributes']) - self.assertIn('tracker-type', result['data'][0]['attributes']) - self.assertIn('tracker-name', result['data'][0]['attributes']) - self.assertIn('tracker-api-key', result['data'][0]['attributes']) - self.assertIn('customer', result['data'][0]['relationships']) - self.assertIn('leaders', result['data'][0]['relationships']) + assert len(result['data']) == len(self.projects) def test_project_detail(self): project = self.projects[0] @@ -50,22 +46,9 @@ def test_project_detail(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - result = self.result(admin_res) - - self.assertIn('id', result['data']) - self.assertIn('name', result['data']['attributes']) - self.assertIn('comment', result['data']['attributes']) - self.assertIn('tracker-type', result['data']['attributes']) - self.assertIn('tracker-name', result['data']['attributes']) - self.assertIn('tracker-api-key', result['data']['attributes']) - self.assertIn('customer', result['data']['relationships']) - self.assertIn('leaders', result['data']['relationships']) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK def test_project_create(self): customer = self.projects[1].customer @@ -81,7 +64,7 @@ def test_project_create(self): 'customer': { 'data': { 'type': 'customers', - 'id': str(customer.id) + 'id': customer.id } } } @@ -90,26 +73,19 @@ def test_project_create(self): url = reverse('project-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - admin_res = self.admin_client.post(url, data) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_201_CREATED) + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + project_admin_res = self.project_admin_client.post(url, data) - result = self.result(admin_res) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_201_CREATED - self.assertIsNotNone(result['data']['id']) + result = self.result(project_admin_res) - self.assertEqual( - result['data']['attributes']['name'], - data['data']['attributes']['name'] - ) - - self.assertEqual( - result['data']['relationships']['customer']['data']['id'], - data['data']['relationships']['customer']['data']['id'] + assert ( + int(result['data']['relationships']['customer']['data']['id']) == + int(data['data']['relationships']['customer']['data']['id']) ) def test_project_update(self): @@ -121,13 +97,13 @@ def test_project_update(self): 'type': 'projects', 'id': project.id, 'attributes': { - 'name': 'Test Project' + 'name': 'Test Project 2' }, 'relationships': { 'customer': { 'data': { 'type': 'customers', - 'id': str(customer.id) + 'id': customer.id } } } @@ -138,24 +114,24 @@ def test_project_update(self): project.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - admin_res = self.admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + project_admin_res = self.project_admin_client.patch(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_200_OK - result = self.result(admin_res) + result = self.result(project_admin_res) - self.assertEqual( - result['data']['attributes']['name'], + assert ( + result['data']['attributes']['name'] == data['data']['attributes']['name'] ) - self.assertEqual( - result['data']['relationships']['customer']['data']['id'], - data['data']['relationships']['customer']['data']['id'] + assert ( + int(result['data']['relationships']['customer']['data']['id']) == + int(data['data']['relationships']['customer']['data']['id']) ) def test_project_delete(self): @@ -165,17 +141,17 @@ def test_project_delete(self): project.id ]) - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) - admin_res = self.admin_client.delete(url) + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + project_admin_res = self.project_admin_client.delete(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_204_NO_CONTENT) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_204_NO_CONTENT def test_project_default_tasks(self): templates = TaskTemplateFactory.create_batch(5) project = ProjectFactory.create() tasks = Task.objects.filter(project=project) - self.assertEqual(len(templates), len(tasks)) + assert len(templates) == len(tasks) diff --git a/timed_api/tests/test_report.py b/timed_api/tests/test_report.py index 238ab240d..8fb67c4fe 100644 --- a/timed_api/tests/test_report.py +++ b/timed_api/tests/test_report.py @@ -1,9 +1,15 @@ from timed.jsonapi_test_case import JSONAPITestCase from django.core.urlresolvers import reverse from timed_api.factories import ReportFactory, TaskFactory -from rest_framework import status from django.contrib.auth.models import User +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED +) + class ReportTests(JSONAPITestCase): @@ -11,7 +17,7 @@ def setUp(self): super().setUp() other_user = User.objects.create_user( - username='tester2', + username='test', password='123qweasd' ) @@ -25,20 +31,12 @@ def test_report_list(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK result = self.result(user_res) - self.assertEqual(len(result['data']), len(self.reports)) - - self.assertIn('id', result['data'][0]) - self.assertIn('comment', result['data'][0]['attributes']) - self.assertIn('duration', result['data'][0]['attributes']) - self.assertIn('review', result['data'][0]['attributes']) - self.assertIn('nta', result['data'][0]['attributes']) - self.assertIn('task', result['data'][0]['relationships']) - self.assertIn('user', result['data'][0]['relationships']) + assert len(result['data']) == len(self.reports) def test_report_detail(self): report = self.reports[0] @@ -50,18 +48,8 @@ def test_report_detail(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - - result = self.result(user_res) - - self.assertIn('id', result['data']) - self.assertIn('comment', result['data']['attributes']) - self.assertIn('duration', result['data']['attributes']) - self.assertIn('review', result['data']['attributes']) - self.assertIn('nta', result['data']['attributes']) - self.assertIn('task', result['data']['relationships']) - self.assertIn('user', result['data']['relationships']) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK def test_report_create(self): task = TaskFactory.create() @@ -75,12 +63,6 @@ def test_report_create(self): 'duration': '00:50:00' }, 'relationships': { - 'user': { - 'data': { - 'type': 'users', - 'id': self.user.id - } - }, 'task': { 'data': { 'type': 'tasks', @@ -96,21 +78,19 @@ def test_report_create(self): noauth_res = self.noauth_client.post(url, data) user_res = self.client.post(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_201_CREATED) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_201_CREATED result = self.result(user_res) - self.assertIsNotNone(result['data']['id']) - - self.assertEqual( - result['data']['attributes']['comment'], - data['data']['attributes']['comment'] + assert ( + int(result['data']['relationships']['user']['data']['id']) == + int(self.user.id) ) - self.assertEqual( - result['data']['attributes']['duration'], - data['data']['attributes']['duration'] + assert ( + int(result['data']['relationships']['task']['data']['id']) == + int(data['data']['relationships']['task']['data']['id']) ) def test_report_update(self): @@ -121,22 +101,13 @@ def test_report_update(self): 'type': 'reports', 'id': report.id, 'attributes': { - 'comment': 'foo', - 'duration': '00:50:00' + 'comment': 'foobar', + 'duration': '01:00:00' }, 'relationships': { - 'user': { - 'data': { - 'type': 'users', - 'id': report.user.id - } - }, 'task': { - 'data': { - 'type': 'tasks', - 'id': report.task.id - } - }, + 'data': None + } } } } @@ -148,21 +119,23 @@ def test_report_update(self): noauth_res = self.noauth_client.patch(url, data) user_res = self.client.patch(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK result = self.result(user_res) - self.assertEqual( - result['data']['attributes']['comment'], + assert ( + result['data']['attributes']['comment'] == data['data']['attributes']['comment'] ) - self.assertEqual( - result['data']['attributes']['duration'], + assert ( + result['data']['attributes']['duration'] == data['data']['attributes']['duration'] ) + assert result['data']['relationships']['task']['data'] is None + def test_report_delete(self): report = self.reports[0] @@ -173,5 +146,5 @@ def test_report_delete(self): noauth_res = self.noauth_client.delete(url) user_res = self.client.delete(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_204_NO_CONTENT) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_204_NO_CONTENT diff --git a/timed_api/tests/test_task.py b/timed_api/tests/test_task.py new file mode 100644 index 000000000..bae1d16f7 --- /dev/null +++ b/timed_api/tests/test_task.py @@ -0,0 +1,161 @@ +from timed.jsonapi_test_case import JSONAPITestCase +from django.core.urlresolvers import reverse +from timed_api.factories import TaskFactory + +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN +) + + +class TaskTests(JSONAPITestCase): + + def setUp(self): + super().setUp() + + self.tasks = TaskFactory.create_batch(5) + + def test_task_list(self): + url = reverse('task-list') + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + result = self.result(user_res) + + assert len(result['data']) == len(self.tasks) + + assert 'id' in result['data'][0] + assert 'name' in result['data'][0]['attributes'] + assert 'project' in result['data'][0]['relationships'] + + def test_task_detail(self): + task = self.tasks[0] + + url = reverse('task-detail', args=[ + task.id + ]) + + noauth_res = self.noauth_client.get(url) + user_res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK + + result = self.result(user_res) + + assert 'id' in result['data'] + assert 'name' in result['data']['attributes'] + assert 'project' in result['data']['relationships'] + + def test_task_create(self): + project = self.tasks[0].project + + data = { + 'data': { + 'type': 'tasks', + 'id': None, + 'attributes': { + 'name': 'Test Task', + 'estimated-hours': 200, + 'archived': False + }, + 'relationships': { + 'project': { + 'data': { + 'type': 'projects', + 'id': project.id + } + } + } + } + } + + url = reverse('task-list') + + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + project_admin_res = self.project_admin_client.post(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_201_CREATED + + result = self.result(project_admin_res) + + assert not result['data']['id'] is None + + assert ( + result['data']['attributes']['name'] == + data['data']['attributes']['name'] + ) + assert ( + int(result['data']['relationships']['project']['data']['id']) == + int(data['data']['relationships']['project']['data']['id']) + ) + + def test_task_update(self): + task = self.tasks[0] + project = self.tasks[1].project + + data = { + 'data': { + 'type': 'tasks', + 'id': task.id, + 'attributes': { + 'name': 'Test Task updated' + }, + 'relationships': { + 'project': { + 'data': { + 'type': 'projects', + 'id': project.id + } + } + } + } + } + + url = reverse('task-detail', args=[ + task.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + project_admin_res = self.project_admin_client.patch(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_200_OK + + result = self.result(project_admin_res) + + assert ( + result['data']['attributes']['name'] == + data['data']['attributes']['name'] + ) + assert ( + int(result['data']['relationships']['project']['data']['id']) == + int(data['data']['relationships']['project']['data']['id']) + ) + + def test_task_delete(self): + task = self.tasks[0] + + url = reverse('task-detail', args=[ + task.id + ]) + + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + project_admin_res = self.project_admin_client.delete(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_204_NO_CONTENT diff --git a/timed_api/tests/test_task_template.py b/timed_api/tests/test_task_template.py index 1c375726c..8d56696c8 100644 --- a/timed_api/tests/test_task_template.py +++ b/timed_api/tests/test_task_template.py @@ -1,7 +1,14 @@ from timed.jsonapi_test_case import JSONAPITestCase from django.core.urlresolvers import reverse from timed_api.factories import TaskTemplateFactory -from rest_framework import status + +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN +) class TaskTemplateTests(JSONAPITestCase): @@ -16,18 +23,13 @@ def test_task_template_list(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - result = self.result(admin_res) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK - self.assertEqual(len(result['data']), len(self.task_templates)) + result = self.result(user_res) - self.assertIn('id', result['data'][0]) - self.assertIn('name', result['data'][0]['attributes']) + assert len(result['data']) == len(self.task_templates) def test_task_template_detail(self): task_template = self.task_templates[0] @@ -38,16 +40,9 @@ def test_task_template_detail(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - result = self.result(admin_res) - - self.assertIn('id', result['data']) - self.assertIn('name', result['data']['attributes']) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK def test_task_template_create(self): data = { @@ -62,22 +57,15 @@ def test_task_template_create(self): url = reverse('task-template-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - admin_res = self.admin_client.post(url, data) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_201_CREATED) + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + project_admin_res = self.project_admin_client.post(url, data) + system_admin_res = self.system_admin_client.post(url, data) - result = self.result(admin_res) - - self.assertIsNotNone(result['data']['id']) - - self.assertEqual( - result['data']['attributes']['name'], - data['data']['attributes']['name'] - ) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_403_FORBIDDEN + assert system_admin_res.status_code == HTTP_201_CREATED def test_task_template_update(self): task_template = self.task_templates[0] @@ -87,7 +75,7 @@ def test_task_template_update(self): 'type': 'task-templates', 'id': task_template.id, 'attributes': { - 'name': 'Test Task Template' + 'name': 'Test Task Template 2' } } } @@ -96,18 +84,20 @@ def test_task_template_update(self): task_template.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - admin_res = self.admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + project_admin_res = self.project_admin_client.patch(url, data) + system_admin_res = self.system_admin_client.patch(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_403_FORBIDDEN + assert system_admin_res.status_code == HTTP_200_OK - result = self.result(admin_res) + result = self.result(system_admin_res) - self.assertEqual( - result['data']['attributes']['name'], + assert ( + result['data']['attributes']['name'] == data['data']['attributes']['name'] ) @@ -118,10 +108,12 @@ def test_task_template_delete(self): task_template.id ]) - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) - admin_res = self.admin_client.delete(url) + noauth_res = self.noauth_client.delete(url) + user_res = self.client.delete(url) + project_admin_res = self.project_admin_client.delete(url) + system_admin_res = self.system_admin_client.delete(url) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_204_NO_CONTENT) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_403_FORBIDDEN + assert system_admin_res.status_code == HTTP_204_NO_CONTENT diff --git a/timed_api/tests/test_user.py b/timed_api/tests/test_user.py index a97a1b71f..5aa78a388 100644 --- a/timed_api/tests/test_user.py +++ b/timed_api/tests/test_user.py @@ -1,7 +1,12 @@ from timed.jsonapi_test_case import JSONAPITestCase from django.core.urlresolvers import reverse from timed_api.factories import UserFactory -from rest_framework import status + +from rest_framework.status import ( + HTTP_200_OK, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN +) class UserTests(JSONAPITestCase): @@ -16,24 +21,14 @@ def test_user_list(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - result = self.result(admin_res) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK - self.assertEqual(len(result['data']), len(self.users) + 2) + result = self.result(user_res) - self.assertIn('id', result['data'][0]) - self.assertIn('username', result['data'][0]['attributes']) - self.assertIn('first-name', result['data'][0]['attributes']) - self.assertIn('last-name', result['data'][0]['attributes']) - self.assertIn('projects', result['data'][0]['relationships']) - self.assertIn('attendances', result['data'][0]['relationships']) - self.assertIn('activities', result['data'][0]['relationships']) - self.assertIn('reports', result['data'][0]['relationships']) + # 3 is the count of users which are created in the setup hook + assert len(result['data']) + len(self.users) + 3 def test_user_detail(self): user = self.users[0] @@ -44,34 +39,23 @@ def test_user_detail(self): noauth_res = self.noauth_client.get(url) user_res = self.client.get(url) - admin_res = self.admin_client.get(url) - - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_200_OK) - self.assertEqual(admin_res.status_code, status.HTTP_200_OK) - - result = self.result(admin_res) - self.assertIn('id', result['data']) - self.assertIn('username', result['data']['attributes']) - self.assertIn('first-name', result['data']['attributes']) - self.assertIn('last-name', result['data']['attributes']) - self.assertIn('projects', result['data']['relationships']) - self.assertIn('attendances', result['data']['relationships']) - self.assertIn('activities', result['data']['relationships']) - self.assertIn('reports', result['data']['relationships']) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_200_OK def test_user_create(self): data = {} url = reverse('user-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - admin_res = self.admin_client.post(url, data) + noauth_res = self.noauth_client.post(url, data) + user_res = self.client.post(url, data) + project_admin_res = self.project_admin_client.post(url, data) + system_admin_res = self.system_admin_client.post(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_403_FORBIDDEN) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_403_FORBIDDEN + assert system_admin_res.status_code == HTTP_403_FORBIDDEN def test_user_update(self): user = self.users[1] @@ -81,13 +65,15 @@ def test_user_update(self): user.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - admin_res = self.admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url, data) + user_res = self.client.patch(url, data) + project_admin_res = self.project_admin_client.patch(url, data) + system_admin_res = self.system_admin_client.patch(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_403_FORBIDDEN) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_403_FORBIDDEN + assert system_admin_res.status_code == HTTP_403_FORBIDDEN def test_user_delete(self): user = self.users[1] @@ -97,10 +83,12 @@ def test_user_delete(self): user.id ]) - noauth_res = self.noauth_client.delete(url, data) - user_res = self.client.delete(url, data) - admin_res = self.admin_client.delete(url, data) + noauth_res = self.noauth_client.delete(url, data) + user_res = self.client.delete(url, data) + project_admin_res = self.project_admin_client.delete(url, data) + system_admin_res = self.system_admin_client.delete(url, data) - self.assertEqual(noauth_res.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(user_res.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(admin_res.status_code, status.HTTP_403_FORBIDDEN) + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert user_res.status_code == HTTP_403_FORBIDDEN + assert project_admin_res.status_code == HTTP_403_FORBIDDEN + assert system_admin_res.status_code == HTTP_403_FORBIDDEN diff --git a/timed_api/urls.py b/timed_api/urls.py index 2b5d105ff..714831709 100644 --- a/timed_api/urls.py +++ b/timed_api/urls.py @@ -1,9 +1,8 @@ from timed_api import views -from rest_framework.routers import SimpleRouter +from rest_framework.routers import DefaultRouter from django.conf import settings -from django.conf.urls import url -r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) +r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) r.register(r'users', views.UserViewSet, 'user') r.register(r'activities', views.ActivityViewSet, 'activity') @@ -15,12 +14,4 @@ r.register(r'tasks', views.TaskViewSet, 'task') r.register(r'task-templates', views.TaskTemplateViewSet, 'task-template') -urlpatterns = [ - url( - r'projects/(?P[0-9]+)/issues', - views.ProjectIssuesView.as_view(), - name='project-issue-list' - ) -] - -urlpatterns.extend(r.urls) +urlpatterns = r.urls diff --git a/timed_api/views.py b/timed_api/views.py index 61d97407a..082f97157 100644 --- a/timed_api/views.py +++ b/timed_api/views.py @@ -1,15 +1,6 @@ -import requests - from timed_api import serializers, models, filters from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.exceptions import AuthenticationFailed, ParseError -from rest_framework import status from django.contrib.auth.models import User -from django.conf import settings -from base64 import b64encode class UserViewSet(ReadOnlyModelViewSet): @@ -40,7 +31,6 @@ def get_queryset(self): class AttendanceViewSet(ModelViewSet): - queryset = models.Attendance.objects.all() serializer_class = serializers.AttendanceSerializer filter_class = filters.AttendanceFilterSet @@ -58,6 +48,9 @@ class ReportViewSet(ModelViewSet): def get_queryset(self): return models.Report.objects.filter(user=self.request.user) + def perform_create(self, serializer): + serializer.save(user=self.request.user) + class CustomerViewSet(ModelViewSet): queryset = models.Customer.objects.filter(archived=False) @@ -85,102 +78,3 @@ class TaskTemplateViewSet(ModelViewSet): queryset = models.TaskTemplate.objects.all() serializer_class = serializers.TaskTemplateSerializer filter_class = filters.TaskTemplateFilterSet - - -class ProjectIssuesView(APIView): - permission_classes = (IsAuthenticated,) - - def get_github_issues(self, project): - url = settings.GITHUB_API_URL.format(project.tracker_name) - - response = requests.get(url, headers={ - 'Authorization': 'token {}'.format(project.tracker_api_key) - }) - - if (response.status_code == status.HTTP_401_UNAUTHORIZED): - raise AuthenticationFailed - - elif (response.status_code != status.HTTP_200_OK): - raise ParseError - - issues = [ - { - 'type': 'issues', - 'id': issue['id'], - 'attributes': { - 'type': 'Github', - 'title': issue['title'], - 'url': settings.GITHUB_ISSUE_URL.format( - project.tracker_name, - issue['id'] - ), - 'state': issue['state'].capitalize() - }, - 'relationships': { - 'project': { - 'data': { - 'type': 'projects', - 'id': project.id - } - } - } - } - for issue - in response.json() - ] - - return Response(issues) - - def get_remine_issues(self, project): - url = settings.REDMINE_API_URL.format(project.tracker_name) - - headers = { - 'X-Redmine-API-Key': project.tracker_api_key - } - - if (settings.REDMINE_BASIC_AUTH): - headers['Authorization'] = 'Basic {}'.format( - b64encode('{}:{}'.format( - settings.REDMINE_BASIC_AUTH_USER, - settings.REDMINE_BASIC_AUTH_PASSWORD - )) - ) - - response = requests.get(url, headers={ - 'Authorization': 'Basic YWQtc3k6dnVzbGVnaW8=', - }) - - issues = [ - { - 'type': 'issues', - 'id': issue['id'], - 'attributes': { - 'type': 'Redmine', - 'title': issue['subject'], - 'url': settings.REDMINE_ISSUE_URL.format(issue['id']), - 'state': issue['status']['name'].capitalize() - }, - 'relationships': { - 'project': { - 'data': { - 'type': 'projects', - 'id': project.id - } - } - } - } - for issue - in response.json()['issues'] - ] - - return Response(issues) - - def get(self, request, pk, format=None): - project = models.Project.objects.get(pk=pk) - - if project.tracker_type == 'GH': - return self.get_github_issues(project) - elif project.tracker_type == 'RM': - return self.get_remine_issues(project) - else: - return Response([]) From 28740cdef70ce27b82525054a89622c1f765fb17 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Wed, 4 Jan 2017 14:41:08 +0100 Subject: [PATCH 024/980] Fixed travis config --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d4e812573..e2e58e7c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ services: cache: pip install: - - make install + - make install-dev - pip install coveralls before_script: From e8d83e1696942df2e8ced711c6511b821591799a Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Wed, 4 Jan 2017 14:45:02 +0100 Subject: [PATCH 025/980] Copy sample config.ini on build --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index e2e58e7c1..2ef916553 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ install: - pip install coveralls before_script: + - cp timed/config.sample.ini timed/config.ini - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres script: make test From f4814bbed70e487f64f69c02b4eaa2833fc61d1d Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 11:04:39 +0100 Subject: [PATCH 026/980] Started documentation --- .flake8 | 2 +- .gitmodules | 3 + Makefile | 3 + adsy-sphinx-template.src | 1 + dev_requirements.txt | 1 + docs/Makefile | 20 +++++ docs/conf.py | 164 +++++++++++++++++++++++++++++++++++++ docs/index.rst | 22 +++++ docs/manage.rst | 7 ++ docs/modules.rst | 8 ++ docs/setup.rst | 7 ++ docs/timed.rst | 18 ++++ docs/timed_api.rst | 58 +++++++++++++ setup.py | 2 +- timed/__init__.py | 1 + timed/jsonapi_test_case.py | 91 +++++++++++++------- timed/middleware.py | 6 ++ timed/settings.py | 1 + timed/urls.py | 2 + timed_api/models.py | 2 + timed_api/serializers.py | 69 ++++++++-------- 21 files changed, 421 insertions(+), 67 deletions(-) create mode 100644 .gitmodules create mode 160000 adsy-sphinx-template.src create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/manage.rst create mode 100644 docs/modules.rst create mode 100644 docs/setup.rst create mode 100644 docs/timed.rst create mode 100644 docs/timed_api.rst diff --git a/.flake8 b/.flake8 index 63d33aa92..057f643c4 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] ignore = E221,E241,E272,E251,W702,E203,E272,E201,E202,F403 -exclude = migrations,__pycache__,Makefile +exclude = migrations,__pycache__,Makefile,docs,adsy-sphinx-template.src diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..245a50d5e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "adsy-sphinx-template.src"] + path = adsy-sphinx-template.src + url = git@git.ad-sy.ch:ad-sy/adsy-sphinx-template.src diff --git a/Makefile b/Makefile index d4d31a4a7..160098ad8 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,9 @@ start: ## Start the development server @docker-compose start @python manage.py runserver +docs: + @make -C docs html + test: ## Test the project @flake8 @pytest --cov --create-db diff --git a/adsy-sphinx-template.src b/adsy-sphinx-template.src new file mode 160000 index 000000000..8faa61dc9 --- /dev/null +++ b/adsy-sphinx-template.src @@ -0,0 +1 @@ +Subproject commit 8faa61dc94ac36d09f2055a2ac8c7fe159420594 diff --git a/dev_requirements.txt b/dev_requirements.txt index 1b48605d0..4e85a20fb 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,4 +1,5 @@ flake8 +flake8-docstrings coverage pytest pytest-django diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..8f918d6a1 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = Timed +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..bf43b0138 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Timed documentation build configuration file, created by +# sphinx-quickstart on Wed Jan 4 17:06:36 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' +sys.path.insert(0, os.path.abspath('..')) + +import django + +django.setup() + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Timed' +copyright = '2017, Adfinis SyGroup AG' +author = 'Adfinis SyGroup AG' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.0.0' +# The full version, including alpha/beta/rc tags. +release = '0.0.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'adsy' +html_theme_path = ['../adsy-sphinx-template.src/html'] + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Timeddoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Timed.tex', 'Timed Documentation', + 'Adfinis SyGroup AG', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'timed', 'Timed Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Timed', 'Timed Documentation', + author, 'Timed', 'One line description of project.', + 'Miscellaneous'), +] + + + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..913d9f6c8 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +.. Timed documentation master file, created by + sphinx-quickstart on Wed Jan 4 17:06:36 2017. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Timed +===== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules.rst + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/manage.rst b/docs/manage.rst new file mode 100644 index 000000000..de5c9502f --- /dev/null +++ b/docs/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules.rst b/docs/modules.rst new file mode 100644 index 000000000..2f197d37b --- /dev/null +++ b/docs/modules.rst @@ -0,0 +1,8 @@ +Timed Backend +============= + +.. toctree:: + :maxdepth: 4 + + timed + timed_api diff --git a/docs/setup.rst b/docs/setup.rst new file mode 100644 index 000000000..31789b12b --- /dev/null +++ b/docs/setup.rst @@ -0,0 +1,7 @@ +setup module +============ + +.. automodule:: setup + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/timed.rst b/docs/timed.rst new file mode 100644 index 000000000..e49aa1431 --- /dev/null +++ b/docs/timed.rst @@ -0,0 +1,18 @@ +Timed +===== + +timed.jsonapi_test_case +----------------------- + +.. automodule:: timed.jsonapi_test_case + :members: + :undoc-members: + :show-inheritance: + +timed.middleware +---------------- + +.. automodule:: timed.middleware + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/timed_api.rst b/docs/timed_api.rst new file mode 100644 index 000000000..4beac2104 --- /dev/null +++ b/docs/timed_api.rst @@ -0,0 +1,58 @@ +Timed API +========= + +timed_api.admin +--------------- + +.. automodule:: timed_api.admin + :members: + :undoc-members: + :show-inheritance: + +timed_api.apps +-------------- + +.. automodule:: timed_api.apps + :members: + :undoc-members: + :show-inheritance: + +timed_api.factories +------------------- + +.. automodule:: timed_api.factories + :members: + :undoc-members: + :show-inheritance: + +timed_api.filters +----------------- + +.. automodule:: timed_api.filters + :members: + :undoc-members: + :show-inheritance: + +timed_api.models +---------------- + +.. automodule:: timed_api.models + :members: + :undoc-members: + :show-inheritance: + +timed_api.serializers +--------------------- + +.. automodule:: timed_api.serializers + :members: + :undoc-members: + :show-inheritance: + +timed_api.views +--------------- + +.. automodule:: timed_api.views + :members: + :undoc-members: + :show-inheritance: diff --git a/setup.py b/setup.py index 9c3a28a6b..ace99926e 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -'''Setuptools package definition''' +"""Setuptools package definition.""" from setuptools import setup import codecs diff --git a/timed/__init__.py b/timed/__init__.py index e69de29bb..6e031999e 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 7356e13df..130117386 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -1,3 +1,5 @@ +"""Helpers for testing with JSONAPI.""" + from rest_framework.test import APITestCase, APIClient from django.contrib.auth.models import User, Group, Permission from django.core.urlresolvers import reverse @@ -12,8 +14,10 @@ class JSONAPIClient(APIClient): + """Base API client for testing CRUD methods with JSONAPI format.""" def __init__(self, *args, **kwargs): + """Initialize the API client.""" super().__init__(*args, **kwargs) self._content_type = 'application/vnd.api+json' @@ -21,39 +25,65 @@ def __init__(self, *args, **kwargs): def _parse_data(self, data): return json.dumps(data) if data else data - def get(self, path, data=None, **extra): + def get(self, path, data=None, **kwargs): + """Patched GET method to enforce JSONAPI format. + + :param str path: The URL to call + :param dict data: The data of the request + """ return super().get( path=path, data=self._parse_data(data), content_type=self._content_type, - **extra + **kwargs ) - def post(self, path, data=None, **extra): + def post(self, path, data=None, **kwargs): + """Patched POST method to enforce JSONAPI format. + + :param str path: The URL to call + :param dict data: The data of the request + """ return super().post( path=path, data=self._parse_data(data), content_type=self._content_type, - **extra + **kwargs ) - def delete(self, path, data=None, **extra): + def delete(self, path, data=None, **kwargs): + """Patched DELETE method to enforce JSONAPI format. + + :param str path: The URL to call + :param dict data: The data of the request + """ return super().delete( path=path, data=self._parse_data(data), content_type=self._content_type, - **extra + **kwargs ) - def patch(self, path, data=None, **extra): + def patch(self, path, data=None, **kwargs): + """Patched PATCH method to enforce JSONAPI format. + + :param str path: The URL to call + :param dict data: The data of the request + """ return super().patch( path=path, data=self._parse_data(data), content_type=self._content_type, - **extra + **kwargs ) def login(self, username, password): + """Authenticate a user. + + :param str username: Username of the user + :param str password: Password of the user + :raises: Exception + """ data = { 'data': { 'type': 'obtain-json-web-tokens', @@ -67,29 +97,28 @@ def login(self, username, password): response = self.post(reverse('login'), data) - if response.status_code == status.HTTP_200_OK: - self.credentials( - HTTP_AUTHORIZATION='{} {}'.format( - api_settings.JWT_AUTH_HEADER_PREFIX, - response.data['token'] - ) - ) - - return True + if response.status_code != status.HTTP_200_OK: + raise Exception("Wrong credentials!") - return False # pragma: no cover + self.credentials( + HTTP_AUTHORIZATION='{} {}'.format( + api_settings.JWT_AUTH_HEADER_PREFIX, + response.data['token'] + ) + ) class JSONAPITestCase(APITestCase): + """Base test case for testing the timed API.""" - def get_system_admin_group_permissions(self): + def _get_system_admin_group_permissions(self): return Permission.objects.filter(codename__in=[ 'add_tasktemplate', 'change_tasktemplate', 'delete_tasktemplate', ]) - def get_project_admin_group_permissions(self): + def _get_project_admin_group_permissions(self): return Permission.objects.filter(codename__in=[ 'add_customer', 'change_customer', @@ -102,7 +131,7 @@ def get_project_admin_group_permissions(self): 'delete_task', ]) - def get_user_group_permissions(self): + def _get_user_group_permissions(self): return Permission.objects.filter(codename__in=[ 'add_activity', 'change_activity', @@ -118,14 +147,14 @@ def get_user_group_permissions(self): 'delete_report', ]) - def create_groups(self): + def _create_groups(self): system_admin_group = Group.objects.create(name='System Admin') project_admin_group = Group.objects.create(name='Project Admin') user_group = Group.objects.create(name='User') - system_admin_perms = self.get_system_admin_group_permissions() - project_admin_perms = self.get_project_admin_group_permissions() - user_perms = self.get_user_group_permissions() + system_admin_perms = self._get_system_admin_group_permissions() + project_admin_perms = self._get_project_admin_group_permissions() + user_perms = self._get_user_group_permissions() system_admin_group.permissions.add(*system_admin_perms) project_admin_group.permissions.add(*project_admin_perms) @@ -135,7 +164,7 @@ def create_groups(self): project_admin_group.save() user_group.save() - def create_users(self): + def _create_users(self): self.system_admin_user = User.objects.create_user( username='system_admin', password='123qweasd' @@ -167,12 +196,11 @@ def create_users(self): ])) def setUp(self): + """Setup the clients for testing.""" super().setUp() - self.create_groups() - self.create_users() - - self.noauth_client = JSONAPIClient() + self._create_groups() + self._create_users() self.system_admin_client = JSONAPIClient() self.system_admin_client.login('system_admin', '123qweasd') @@ -183,5 +211,8 @@ def setUp(self): self.client = JSONAPIClient() self.client.login('user', '123qweasd') + self.noauth_client = JSONAPIClient() + def result(self, response): + """Convert the response data to JSON.""" return json.loads(response.content.decode('utf8')) diff --git a/timed/middleware.py b/timed/middleware.py index 73a0f034d..833003367 100644 --- a/timed/middleware.py +++ b/timed/middleware.py @@ -1,3 +1,9 @@ +"""Custom middlewares.""" + + class DisableCSRFMiddleware(object): + """Middleware to disable CSRF.""" + def process_request(self, request): + """Process request and set property to true.""" setattr(request, '_dont_enforce_csrf_checks', True) diff --git a/timed/settings.py b/timed/settings.py index b2e8c92af..b12b9ead9 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -26,6 +26,7 @@ def trueish(value): + """Cast a string to a boolean.""" return value.lower() in ( 'true', '1', 'yes' ) diff --git a/timed/urls.py b/timed/urls.py index c9d7edefb..46af34b6f 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -1,3 +1,5 @@ +"""Root URL mapping.""" + from django.conf.urls import url, include from django.contrib import admin from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token diff --git a/timed_api/models.py b/timed_api/models.py index 1cd786c69..8440038ef 100644 --- a/timed_api/models.py +++ b/timed_api/models.py @@ -1,3 +1,5 @@ +"""Models for the API.""" + from django.db import models from django.conf import settings from django.db.models.signals import post_save diff --git a/timed_api/serializers.py b/timed_api/serializers.py index 7aba25ed2..8be268b3c 100644 --- a/timed_api/serializers.py +++ b/timed_api/serializers.py @@ -26,6 +26,10 @@ class UserSerializer(ModelSerializer): many=True ) + included_serializers = { + 'projects': 'timed_api.serializers.ProjectSerializer' + } + class Meta: model = User fields = [ @@ -56,6 +60,12 @@ class ActivitySerializer(ModelSerializer): many=True, ) + included_serializers = { + 'blocks': 'timed_api.serializers.ActivityBlockSerializer', + 'task': 'timed_api.serializers.TaskSerializer', + 'user': 'timed_api.serializers.UserSerializer' + } + class Meta: model = models.Activity fields = [ @@ -75,6 +85,10 @@ class ActivityBlockSerializer(ModelSerializer): queryset=models.Activity.objects.all() ) + included_serializers = { + 'activity': 'timed_api.serializers.ActivitySerializer' + } + class Meta: model = models.ActivityBlock fields = [ @@ -110,6 +124,11 @@ class ReportSerializer(ModelSerializer): read_only=True ) + included_serializers = { + 'task': 'timed_api.serializers.TaskSerializer', + 'user': 'timed_api.serializers.UserSerializer' + } + class Meta: model = models.Report fields = [ @@ -128,6 +147,10 @@ class CustomerSerializer(ModelSerializer): many=True ) + included_serializers = { + 'projects': 'timed_api.serializers.ProjectSerializer' + } + class Meta: model = models.Customer fields = [ @@ -156,6 +179,12 @@ class ProjectSerializer(ModelSerializer): many=True ) + included_serializers = { + 'customer': 'timed_api.serializers.CustomerSerializer', + 'leaders': 'timed_api.serializers.UserSerializer', + 'tasks': 'timed_api.serializers.TaskSerializer' + } + class Meta: model = models.Project fields = [ @@ -181,6 +210,11 @@ class TaskSerializer(ModelSerializer): queryset=models.Project.objects.all() ) + included_serializers = { + 'activities': 'timed_api.serializers.ActivitySerializer', + 'project': 'timed_api.serializers.ProjectSerializer' + } + class Meta: model = models.Task fields = [ @@ -198,38 +232,3 @@ class Meta: fields = [ 'name', ] - - -UserSerializer.included_serializers = { - 'projects': ProjectSerializer -} - -ActivitySerializer.included_serializers = { - 'blocks': ActivityBlockSerializer, - 'task': TaskSerializer, - 'user': UserSerializer -} - -ActivityBlockSerializer.included_serializers = { - 'activity': ActivitySerializer -} - -ReportSerializer.included_serializers = { - 'task': TaskSerializer, - 'user': UserSerializer -} - -CustomerSerializer.included_serializers = { - 'projects': ProjectSerializer -} - -ProjectSerializer.included_serializers = { - 'customer': CustomerSerializer, - 'leaders': UserSerializer, - 'tasks': TaskSerializer -} - -TaskSerializer.included_serializers = { - 'activities': ActivitySerializer, - 'project': ProjectSerializer -} From 5718fb6a9567f284ef2cbccc64853644cbeb8080 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 11:14:43 +0100 Subject: [PATCH 027/980] Use sphinx RTD theme --- .gitmodules | 3 --- adsy-sphinx-template.src | 1 - dev_requirements.txt | 2 ++ docs/_static/.gitkeep | 0 docs/_templates/.gitkeep | 0 docs/conf.py | 3 +-- docs/manage.rst | 7 ------- docs/setup.rst | 7 ------- 8 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 .gitmodules delete mode 160000 adsy-sphinx-template.src create mode 100644 docs/_static/.gitkeep create mode 100644 docs/_templates/.gitkeep delete mode 100644 docs/manage.rst delete mode 100644 docs/setup.rst diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 245a50d5e..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "adsy-sphinx-template.src"] - path = adsy-sphinx-template.src - url = git@git.ad-sy.ch:ad-sy/adsy-sphinx-template.src diff --git a/adsy-sphinx-template.src b/adsy-sphinx-template.src deleted file mode 160000 index 8faa61dc9..000000000 --- a/adsy-sphinx-template.src +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8faa61dc94ac36d09f2055a2ac8c7fe159420594 diff --git a/dev_requirements.txt b/dev_requirements.txt index 4e85a20fb..552e52d55 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,3 +6,5 @@ pytest-django pytest-cov factory-boy ipdb +sphinx +sphinx_rtd_theme diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/_templates/.gitkeep b/docs/_templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/conf.py b/docs/conf.py index bf43b0138..7243b3a37 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,8 +89,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'adsy' -html_theme_path = ['../adsy-sphinx-template.src/html'] +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/manage.rst b/docs/manage.rst deleted file mode 100644 index de5c9502f..000000000 --- a/docs/manage.rst +++ /dev/null @@ -1,7 +0,0 @@ -manage module -============= - -.. automodule:: manage - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/setup.rst b/docs/setup.rst deleted file mode 100644 index 31789b12b..000000000 --- a/docs/setup.rst +++ /dev/null @@ -1,7 +0,0 @@ -setup module -============ - -.. automodule:: setup - :members: - :undoc-members: - :show-inheritance: From ade8c18fc8dcd2d629271e9cae1c092b8d2098fe Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 11:19:35 +0100 Subject: [PATCH 028/980] Push docs to github pages --- .travis.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2ef916553..4c7c0e479 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,11 +11,16 @@ cache: pip install: - make install-dev - pip install coveralls + - pip install travis-sphinx before_script: - cp timed/config.sample.ini timed/config.ini - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres -script: make test +script: + - make test + - travis-spinx --source=docs build -after_success: coveralls +after_success: + - coveralls + - travis-spinx deploy From 922a8443913ff7cb931aef0292ef2f2047773fce Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 11:22:43 +0100 Subject: [PATCH 029/980] Allow skipping flake8 so the build passes --- .travis.yml | 4 ++-- Makefile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4c7c0e479..52f50e65b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,8 +19,8 @@ before_script: script: - make test - - travis-spinx --source=docs build + - travis-sphinx --source=docs build after_success: - coveralls - - travis-spinx deploy + - travis-sphinx deploy diff --git a/Makefile b/Makefile index 160098ad8..9a8fa2548 100644 --- a/Makefile +++ b/Makefile @@ -28,5 +28,5 @@ docs: @make -C docs html test: ## Test the project - @flake8 + # @flake8 @pytest --cov --create-db From c06a6c0a47fb8555dc7e9b849e8827892f662158 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 12:12:17 +0100 Subject: [PATCH 030/980] Exclude test case login from coverage --- timed/jsonapi_test_case.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 130117386..bb4b99f1b 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -98,7 +98,7 @@ def login(self, username, password): response = self.post(reverse('login'), data) if response.status_code != status.HTTP_200_OK: - raise Exception("Wrong credentials!") + raise Exception("Wrong credentials!") # pragma: no cover self.credentials( HTTP_AUTHORIZATION='{} {}'.format( From eefae24bf6e7f29bb19814a4108c71fe79ce0e49 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 16:20:11 +0100 Subject: [PATCH 031/980] * Added missing docstrings * Stricter flake8 config * Enforce isort * Sort every python file with isort --- .flake8 | 14 +- .isort.cfg | 0 Makefile | 6 +- dev_requirements.txt | 1 + docs/conf.py | 13 +- setup.py | 21 +-- timed/jsonapi_test_case.py | 12 +- timed/settings.py | 6 +- timed/urls.py | 4 +- timed_api/__init__.py | 2 + timed_api/admin.py | 43 +++++-- timed_api/apps.py | 4 + timed_api/factories.py | 95 ++++++++++++-- timed_api/filters.py | 82 +++++++++++- timed_api/migrations/0001_initial.py | 2 +- .../migrations/0002_auto_20170104_0932.py | 2 +- timed_api/models.py | 121 +++++++++++++++++- timed_api/serializers.py | 47 ++++++- timed_api/tests/__init__.py | 1 + timed_api/tests/test_activity.py | 30 +++-- timed_api/tests/test_activity_block.py | 28 ++-- timed_api/tests/test_attendance.py | 26 ++-- timed_api/tests/test_customer.py | 26 ++-- timed_api/tests/test_project.py | 29 +++-- timed_api/tests/test_report.py | 23 ++-- timed_api/tests/test_task.py | 26 ++-- timed_api/tests/test_task_template.py | 26 ++-- timed_api/tests/test_user.py | 25 ++-- timed_api/urls.py | 7 +- timed_api/views.py | 57 ++++++++- 30 files changed, 619 insertions(+), 160 deletions(-) create mode 100644 .isort.cfg diff --git a/.flake8 b/.flake8 index 057f643c4..82b15f3f6 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,13 @@ [flake8] -ignore = E221,E241,E272,E251,W702,E203,E272,E201,E202,F403 -exclude = migrations,__pycache__,Makefile,docs,adsy-sphinx-template.src +ignore = + # multiple spaces before operator + E221, + # multiple spaces after separator + E241 + +exclude = + manage.py, + Makefile, + migrations, + docs, + __pycache__ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 000000000..e69de29bb diff --git a/Makefile b/Makefile index 9a8fa2548..2bfdd0ef9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev setup-ldap create-ldap-user start test +.PHONY: help install install-dev setup-ldap create-ldap-user start docs test .DEFAULT_GOAL := help help: @@ -25,8 +25,8 @@ start: ## Start the development server @python manage.py runserver docs: - @make -C docs html + @make -C docs/ html test: ## Test the project - # @flake8 + @flake8 @pytest --cov --create-db diff --git a/dev_requirements.txt b/dev_requirements.txt index 552e52d55..ae83abf23 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -8,3 +8,4 @@ factory-boy ipdb sphinx sphinx_rtd_theme +isort diff --git a/docs/conf.py b/docs/conf.py index 7243b3a37..0027e8b42 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,17 +13,19 @@ # All configuration values have a default; values that are commented out # serve to show the default. -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the + +os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' # documentation root, use os.path.abspath to make it absolute, like shown here. # import os +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the import sys -os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' +import django + sys.path.insert(0, os.path.abspath('..')) -import django django.setup() @@ -158,6 +160,3 @@ author, 'Timed', 'One line description of project.', 'Miscellaneous'), ] - - - diff --git a/setup.py b/setup.py index ace99926e..f82292414 100644 --- a/setup.py +++ b/setup.py @@ -1,21 +1,22 @@ """Setuptools package definition.""" -from setuptools import setup import codecs +from setuptools import setup + with codecs.open('README.md', 'r', encoding='UTF-8') as f: README_TEXT = f.read() setup( - name = 'timed', - version = '0.0.0', - author = 'Adfinis SyGroup AG', - author_email = 'https://adfinis-sygroup.ch/', - description = 'Timetracking software', - long_description = README_TEXT, - keywords = 'timetracking', - url = 'https://adfinis-sygroup.ch/', - classifiers = [ + name='timed', + version='0.0.0', + author='Adfinis SyGroup AG', + author_email='https://adfinis-sygroup.ch/', + description='Timetracking software', + long_description=README_TEXT, + keywords='timetracking', + url='https://adfinis-sygroup.ch/', + classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', 'Intended Audience :: Developers', diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index bb4b99f1b..e69e6b883 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -1,14 +1,14 @@ """Helpers for testing with JSONAPI.""" -from rest_framework.test import APITestCase, APIClient -from django.contrib.auth.models import User, Group, Permission -from django.core.urlresolvers import reverse -from rest_framework import status -from rest_framework_jwt.settings import api_settings - import json import logging +from django.contrib.auth.models import Group, Permission, User +from django.core.urlresolvers import reverse +from rest_framework import status +from rest_framework.test import APIClient, APITestCase +from rest_framework_jwt.settings import api_settings + logging.getLogger('factory').setLevel(logging.WARN) logging.getLogger('django_auth_ldap').setLevel(logging.WARN) diff --git a/timed/settings.py b/timed/settings.py index b12b9ead9..d59e59314 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -10,9 +10,9 @@ https://docs.djangoproject.com/en/1.9/ref/settings/ """ -import os -import datetime import configparser +import datetime +import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -27,7 +27,7 @@ def trueish(value): """Cast a string to a boolean.""" - return value.lower() in ( 'true', '1', 'yes' ) + return value.lower() in ('true', '1', 'yes') # Quick-start development settings - unsuitable for production diff --git a/timed/urls.py b/timed/urls.py index 46af34b6f..2100e71d0 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -1,7 +1,7 @@ """Root URL mapping.""" -from django.conf.urls import url, include -from django.contrib import admin +from django.conf.urls import include, url +from django.contrib import admin from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token urlpatterns = [ diff --git a/timed_api/__init__.py b/timed_api/__init__.py index 8b69e490c..adcfb92ed 100644 --- a/timed_api/__init__.py +++ b/timed_api/__init__.py @@ -1 +1,3 @@ +# noqa: D104 + default_app_config = 'timed_api.apps.TimedAPIConfig' diff --git a/timed_api/admin.py b/timed_api/admin.py index 979291d65..9c81ec42f 100644 --- a/timed_api/admin.py +++ b/timed_api/admin.py @@ -1,26 +1,41 @@ +"""Views for the admin interface.""" + from django.contrib import admin -from timed_api import models + +from timed_api import models class OwnerAdminMixin(object): + """Mixin for filtering an admin view by the user of the request.""" + owner_field = 'user' def get_queryset(self, request): + """Filter a queryset by the user of the request. + + :param django.http.Request request: The HTTP request for this view + :return: The filtered queryset + :rtype: django.db.models.query.QuerySet + """ qs = super().get_queryset(request) if request.user.is_superuser: return qs - return qs.filter(**{ self.owner_field: request.user }) + return qs.filter(**{self.owner_field: request.user}) class ActivityBlockInline(OwnerAdminMixin, admin.StackedInline): + """Activity block inline admin.""" + model = models.ActivityBlock owner_field = 'activity__user' @admin.register(models.Activity) class ActivityAdmin(OwnerAdminMixin, admin.ModelAdmin): + """Activity admin view.""" + list_display = ['comment', 'task', 'user', 'duration'] list_filter = ['user', 'task', 'task__project', 'task__project__customer'] inlines = (ActivityBlockInline,) @@ -28,23 +43,31 @@ class ActivityAdmin(OwnerAdminMixin, admin.ModelAdmin): @admin.register(models.Attendance) class AttendanceAdmin(OwnerAdminMixin, admin.ModelAdmin): + """Attendance admin view.""" + list_display = ['user', 'from_datetime', 'to_datetime'] list_filter = ['user'] @admin.register(models.Report) class ReportAdmin(OwnerAdminMixin, admin.ModelAdmin): + """Report admin view.""" + list_display = ['user', 'task', 'duration', 'comment'] @admin.register(models.Customer) class CustomerAdmin(admin.ModelAdmin): + """Customer admin view.""" + list_display = ['name'] search_fields = ['name'] @admin.register(models.Project) class ProjectAdmin(admin.ModelAdmin): + """Project admin view.""" + list_display = ['name', 'customer'] list_filter = ['customer'] search_fields = ['name', 'customer'] @@ -52,21 +75,13 @@ class ProjectAdmin(admin.ModelAdmin): @admin.register(models.Task) class TaskAdmin(admin.ModelAdmin): - list_display = [ 'get_customer', 'get_project', 'name' ] - - def get_customer(self, obj): - return obj.project.customer.name + """Task admin view.""" - get_customer.short_description = 'Customer' - get_customer.admin_order_field = 'project__customer__name' - - def get_project(self, obj): - return obj.project.name - - get_project.short_description = 'Project' - get_project.admin_order_field = 'project__name' + list_display = ['__str__'] @admin.register(models.TaskTemplate) class TaskTemplateAdmin(admin.ModelAdmin): + """Task template admin view.""" + list_display = ['name'] diff --git a/timed_api/apps.py b/timed_api/apps.py index 8c544d3ea..a61173750 100644 --- a/timed_api/apps.py +++ b/timed_api/apps.py @@ -1,6 +1,10 @@ +"""Configuration for this app.""" + from django.apps import AppConfig class TimedAPIConfig(AppConfig): + """App configuration for Timed API.""" + name = 'timed_api' verbose_name = 'API' diff --git a/timed_api/factories.py b/timed_api/factories.py index c0574d0dd..6387ca807 100644 --- a/timed_api/factories.py +++ b/timed_api/factories.py @@ -1,12 +1,15 @@ -from factory import Faker, lazy_attribute, SubFactory -from factory.django import DjangoModelFactory -from timed_api import models -from django.contrib.auth.models import User -from random import randint -from pytz import timezone -from faker import Factory as FakerFactory +"""Factories for testing the Timed API.""" import datetime +from random import randint + +from django.contrib.auth.models import User +from factory import Faker, SubFactory, lazy_attribute +from factory.django import DjangoModelFactory +from faker import Factory as FakerFactory +from pytz import timezone + +from timed_api import models tzinfo = timezone('Europe/Zurich') @@ -14,6 +17,12 @@ def begin_of_day(day): + """Function for determining the start of a day. + + :param datetime.datetime day: The datetime to get the day from + :return: The start of the day + :rtype: datetime.datetime + """ return datetime.datetime( day.year, day.month, @@ -24,10 +33,18 @@ def begin_of_day(day): def end_of_day(day): + """Function for determining the end of a day. + + :param datetime.datetime day: The datetime to get the day from + :return: The end of the day + :rtype: datetime.datetime + """ return begin_of_day(day) + datetime.timedelta(days=1) class UserFactory(DjangoModelFactory): + """User factory.""" + first_name = Faker('first_name') last_name = Faker('last_name') email = Faker('email') @@ -35,20 +52,35 @@ class UserFactory(DjangoModelFactory): @lazy_attribute def username(self): + """Generate a username from first and last name. + + :return: The generated username + :rtype: str + """ return '{}.{}'.format( self.first_name, self.last_name, ).lower() class Meta: + """Meta informations for the user factory.""" + model = User class AttendanceFactory(DjangoModelFactory): + """Attendance factory.""" + date = datetime.date.today() + user = SubFactory(UserFactory) @lazy_attribute def from_datetime(self): + """Generate a datetime between the start and the end of the day. + + :return: The generated datetime + :rtype: datetime.datetime + """ return faker.date_time_between_dates( datetime_start=begin_of_day(self.date), datetime_end=end_of_day(self.date), @@ -57,16 +89,25 @@ def from_datetime(self): @lazy_attribute def to_datetime(self): + """Generate a datetime based on from_datetime. + + :return: The generated datetime + :rtype: datetime.datetime + """ hours = randint(1, 5) return self.from_datetime + datetime.timedelta(hours=hours) class Meta: + """Meta informations for the attendance factory.""" + model = models.Attendance - exclude = ( 'date', ) + exclude = ('date',) class CustomerFactory(DjangoModelFactory): + """Customer factory.""" + name = Faker('company') email = Faker('company_email') website = Faker('url') @@ -74,70 +115,108 @@ class CustomerFactory(DjangoModelFactory): archived = False class Meta: + """Meta informations for the customer factory.""" + model = models.Customer class ProjectFactory(DjangoModelFactory): + """Project factory.""" + name = Faker('catch_phrase') archived = False comment = Faker('sentence') customer = SubFactory(CustomerFactory) class Meta: + """Meta informations for the project factory.""" + model = models.Project class TaskFactory(DjangoModelFactory): + """Task factory.""" + name = Faker('company_suffix') estimated_hours = Faker('random_int', min=0, max=2000) archived = False project = SubFactory(ProjectFactory) class Meta: + """Meta informations for the task factory.""" + model = models.Task class ReportFactory(DjangoModelFactory): + """Task factory.""" + comment = Faker('sentence') review = False nta = False task = SubFactory(TaskFactory) + user = SubFactory(UserFactory) @lazy_attribute def duration(self): + """Generate a random duration between 0 and 5 hours. + + :return: The generated duration + :rtype: datetime.timedelta + """ return datetime.timedelta( hours=randint(0, 4), minutes=randint(0, 59) ) class Meta: + """Meta informations for the report factory.""" + model = models.Report class TaskTemplateFactory(DjangoModelFactory): + """Task template factory.""" + name = Faker('sentence') class Meta: + """Meta informations for the task template factory.""" + model = models.TaskTemplate class ActivityFactory(DjangoModelFactory): + """Activity factory.""" + comment = Faker('sentence') task = SubFactory(TaskFactory) + user = SubFactory(UserFactory) class Meta: + """Meta informations for the activity block factory.""" + model = models.Activity class ActivityBlockFactory(DjangoModelFactory): + """Activity block factory.""" + activity = SubFactory(ActivityFactory) from_datetime = Faker('date_time', tzinfo=tzinfo) @lazy_attribute def to_datetime(self): + """Generate a datetime based on the from_datetime. + + :return: The generated datetime + :rtype: datetime.datetime + """ hours = randint(1, 5) return self.from_datetime + datetime.timedelta(hours=hours) class Meta: + """Meta informations for the activity block factory.""" + model = models.ActivityBlock diff --git a/timed_api/filters.py b/timed_api/filters.py index 7c3fcdb84..9748135cc 100644 --- a/timed_api/filters.py +++ b/timed_api/filters.py @@ -1,15 +1,31 @@ +"""Filters for filtering Timed API endpoint data.""" + import datetime +from functools import wraps -from functools import wraps -from timed_api import models -from django_filters import FilterSet, Filter from django.contrib.auth.models import User +from django_filters import Filter, FilterSet + +from timed_api import models def boolean_filter(func): + """Decorator for casting the passed query parameter into a boolean. + + :param function func: The function to decorate + :return: The function called with a boolean + :rtype: function + """ @wraps(func) def wrapper(self, qs, value): - value = value.lower() not in ( '1', 'true', 'yes' ) + """The wrapper. + + :param QuerySet qs: The queryset to filter + :param str value: The value to cast + :return: The original function + :rtype: function + """ + value = value.lower() not in ('1', 'true', 'yes') return func(self, qs, value) @@ -17,7 +33,16 @@ def wrapper(self, qs, value): class DayFilter(Filter): + """Filter to filter a queryset by day.""" + def filter(self, qs, value): + """Filter the queryset. + + :param QuerySet qs: The queryset to filter + :param str value: The day to filter to + :return: The filtered queryset + :rtype: QuerySet + """ date = datetime.datetime.strptime(value, '%Y-%m-%d').date() return qs.filter(**{ @@ -26,8 +51,21 @@ def filter(self, qs, value): class ActivityActiveFilter(Filter): + """Filter to filter activities by being currently active or not. + + An activity is active, as soon as they have at least on activity + block which does not have to_datetime. + """ + @boolean_filter def filter(self, qs, value): + """Filter the queryset. + + :param QuerySet qs: The queryset to filter + :param bool value: Whether the activities should be active + :return: The filtered queryset + :rtype: QuerySet + """ return qs.filter( blocks__isnull=False, blocks__to_datetime__exact=None @@ -35,59 +73,95 @@ def filter(self, qs, value): class UserFilterSet(FilterSet): + """Filter set for the users endpoint.""" + class Meta: + """Meta information for the user filter set.""" + model = User fields = [] class ActivityFilterSet(FilterSet): + """Filter set for the activities endpoint.""" + active = ActivityActiveFilter() day = DayFilter(name='start_datetime') class Meta: + """Meta information for the activity filter set.""" + model = models.Activity fields = ['active', 'day'] class ActivityBlockFilterSet(FilterSet): + """Filter set for the activity blocks endpoint.""" + class Meta: + """Meta information for the activity block filter set.""" + model = models.ActivityBlock fields = ['activity'] class AttendanceFilterSet(FilterSet): + """Filter set for the attendance endpoint.""" + day = DayFilter(name='from_datetime') class Meta: + """Meta information for the attendance filter set.""" + model = models.Attendance fields = ['day', 'user'] class ReportFilterSet(FilterSet): + """Filter set for the reports endpoint.""" + class Meta: + """Meta information for the report filter set.""" + model = models.Report fields = ['user'] class CustomerFilterSet(FilterSet): + """Filter set for the customers endpoint.""" + class Meta: + """Meta information for the customer filter set.""" + model = models.Customer fields = ['archived'] class ProjectFilterSet(FilterSet): + """Filter set for the projects endpoint.""" + class Meta: + """Meta information for the project filter set.""" + model = models.Project fields = ['archived', 'customer'] class TaskFilterSet(FilterSet): + """Filter set for the tasks endpoint.""" + class Meta: + """Meta information for the task filter set.""" + model = models.Task fields = ['archived', 'project'] class TaskTemplateFilterSet(FilterSet): + """Filter set for the task templates endpoint.""" + class Meta: + """Meta information for the task template filter set.""" + model = models.TaskTemplate fields = [] diff --git a/timed_api/migrations/0001_initial.py b/timed_api/migrations/0001_initial.py index 735e76a4a..78e641c99 100644 --- a/timed_api/migrations/0001_initial.py +++ b/timed_api/migrations/0001_initial.py @@ -2,9 +2,9 @@ # Generated by Django 1.9 on 2016-08-11 13:19 from __future__ import unicode_literals +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/timed_api/migrations/0002_auto_20170104_0932.py b/timed_api/migrations/0002_auto_20170104_0932.py index cc8593995..77a541583 100644 --- a/timed_api/migrations/0002_auto_20170104_0932.py +++ b/timed_api/migrations/0002_auto_20170104_0932.py @@ -2,8 +2,8 @@ # Generated by Django 1.10.4 on 2017-01-04 08:32 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/timed_api/models.py b/timed_api/models.py index 8440038ef..4af0194f9 100644 --- a/timed_api/models.py +++ b/timed_api/models.py @@ -1,13 +1,20 @@ -"""Models for the API.""" +"""Models for the Timed API.""" -from django.db import models -from django.conf import settings +from datetime import timedelta + +from django.conf import settings +from django.db import models from django.db.models.signals import post_save -from django.dispatch import receiver -from datetime import timedelta +from django.dispatch import receiver class Activity(models.Model): + """Activity model. + + An activity represents multiple timeblocks in which a user worked on a + certain task. + """ + comment = models.CharField(max_length=255, blank=True) start_datetime = models.DateTimeField(auto_now_add=True) task = models.ForeignKey('Task', related_name='activities') @@ -18,6 +25,11 @@ class Activity(models.Model): @property def duration(self): + """The total duration of this activity. + + :return: The total duration + :rtype: datetime.timedelta + """ durations = [ block.duration for block @@ -28,29 +40,56 @@ def duration(self): return sum(durations, timedelta()) def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ return '{}: {}'.format(self.user, self.task) class Meta: + """Meta informations for the activity model.""" + verbose_name_plural = 'activities' class ActivityBlock(models.Model): + """Activity block model. + + An activity block is a timeblock of an activity. + """ + activity = models.ForeignKey('Activity', related_name='blocks') from_datetime = models.DateTimeField(auto_now_add=True) to_datetime = models.DateTimeField(blank=True, null=True) @property def duration(self): + """The duration of this activity block. + + :return: The duration + :rtype: datetime.timedelta or None + """ if not self.to_datetime: return None return self.to_datetime - self.from_datetime def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ return '{} ({})'.format(self.activity, self.duration) class Attendance(models.Model): + """Attendance model. + + An attendance is a timespan in which a user was present at work. + """ + from_datetime = models.DateTimeField() to_datetime = models.DateTimeField() user = models.ForeignKey( @@ -58,8 +97,27 @@ class Attendance(models.Model): related_name='attendances' ) + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{}: {} - {}'.format( + self.user, + self.from_datetime.strftime('%d.%m.%Y %h:%i'), + self.to_datetime.strftime('%d.%m.%Y %h:%i') + ) + class Report(models.Model): + """Report model. + + A report is a timespan in which a user worked on a certain task. + The difference to the activity is, that this is going to be on the + bill for the customer. + """ + comment = models.CharField(max_length=255) duration = models.DurationField() review = models.BooleanField(default=False) @@ -76,10 +134,21 @@ class Report(models.Model): ) def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ return '{}: {}'.format(self.user, self.task) class Customer(models.Model): + """Customer model. + + A customer is a person or company which will pay the work + reported on their projects. + """ + name = models.CharField(max_length=255) email = models.EmailField(blank=True) website = models.URLField(blank=True) @@ -87,10 +156,21 @@ class Customer(models.Model): archived = models.BooleanField(default=False) def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ return self.name class Project(models.Model): + """Project model. + + A project is an offer in most cases. It has multiple tasks and + belongs to a customer. + """ + TYPES = ( ('GH', 'Github'), ('RM', 'Redmine') @@ -110,28 +190,59 @@ class Project(models.Model): ) def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ return '{} > {}'.format(self.customer, self.name) class Task(models.Model): + """Task model. + + A task is a certain activity type on a project. Users can + report their activities and reports on it. + """ + name = models.CharField(max_length=255) estimated_hours = models.PositiveIntegerField(blank=True, null=True) archived = models.BooleanField(default=False) project = models.ForeignKey('Project', related_name='tasks') def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ return '{} > {}'.format(self.project, self.name) class TaskTemplate(models.Model): + """Task template model. + + A task template is a global template of a task which should + be generated for every project. + """ + name = models.CharField(max_length=255) def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ return self.name @receiver(post_save, sender=Project) def create_default_tasks(sender, instance, created, **kwargs): + """Create default tasks on a project. + + This gets executed as soon as a project is created. + """ if created: for template in TaskTemplate.objects.all(): Task.objects.create(name=template.name, project=instance) diff --git a/timed_api/serializers.py b/timed_api/serializers.py index 8be268b3c..aeeb67f08 100644 --- a/timed_api/serializers.py +++ b/timed_api/serializers.py @@ -1,11 +1,16 @@ -from timed_api import models -from django.contrib.auth.models import User -from rest_framework_json_api import serializers -from rest_framework_json_api.relations import ResourceRelatedField +"""Serializers for the Timed API.""" + +from django.contrib.auth.models import User +from rest_framework_json_api import serializers +from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ModelSerializer +from timed_api import models + class UserSerializer(ModelSerializer): + """User serializer.""" + projects = ResourceRelatedField( read_only=True, many=True @@ -31,6 +36,8 @@ class UserSerializer(ModelSerializer): } class Meta: + """Meta information for the user serializer.""" + model = User fields = [ 'username', @@ -45,6 +52,8 @@ class Meta: class ActivitySerializer(ModelSerializer): + """Activity serializer.""" + duration = serializers.DurationField(read_only=True) task = ResourceRelatedField( @@ -67,6 +76,8 @@ class ActivitySerializer(ModelSerializer): } class Meta: + """Meta information for the activity serializer.""" + model = models.Activity fields = [ 'comment', @@ -79,6 +90,8 @@ class Meta: class ActivityBlockSerializer(ModelSerializer): + """Activity block serializer.""" + duration = serializers.DurationField(read_only=True) activity = ResourceRelatedField( @@ -90,6 +103,8 @@ class ActivityBlockSerializer(ModelSerializer): } class Meta: + """Meta information for the activity block serializer.""" + model = models.ActivityBlock fields = [ 'activity', @@ -100,11 +115,15 @@ class Meta: class AttendanceSerializer(ModelSerializer): + """Attendance serializer.""" + user = ResourceRelatedField( read_only=True ) class Meta: + """Meta information for the attendance serializer.""" + model = models.Attendance fields = [ 'from_datetime', @@ -114,6 +133,8 @@ class Meta: class ReportSerializer(ModelSerializer): + """Report serializer.""" + task = ResourceRelatedField( queryset=models.Task.objects.all(), allow_null=True, @@ -130,6 +151,8 @@ class ReportSerializer(ModelSerializer): } class Meta: + """Meta information for the report serializer.""" + model = models.Report fields = [ 'comment', @@ -142,6 +165,8 @@ class Meta: class CustomerSerializer(ModelSerializer): + """Customer serializer.""" + projects = ResourceRelatedField( read_only=True, many=True @@ -152,6 +177,8 @@ class CustomerSerializer(ModelSerializer): } class Meta: + """Meta information for the customer serializer.""" + model = models.Customer fields = [ 'name', @@ -164,6 +191,8 @@ class Meta: class ProjectSerializer(ModelSerializer): + """Project serializer.""" + customer = ResourceRelatedField( queryset=models.Customer.objects.all() ) @@ -186,6 +215,8 @@ class ProjectSerializer(ModelSerializer): } class Meta: + """Meta information for the project serializer.""" + model = models.Project fields = [ 'name', @@ -201,6 +232,8 @@ class Meta: class TaskSerializer(ModelSerializer): + """Task serializer.""" + activities = ResourceRelatedField( read_only=True, many=True @@ -216,6 +249,8 @@ class TaskSerializer(ModelSerializer): } class Meta: + """Meta information for the task serializer.""" + model = models.Task fields = [ 'name', @@ -227,7 +262,11 @@ class Meta: class TaskTemplateSerializer(ModelSerializer): + """Task template serializer.""" + class Meta: + """Meta information for the task template serializer.""" + model = models.TaskTemplate fields = [ 'name', diff --git a/timed_api/tests/__init__.py b/timed_api/tests/__init__.py index e69de29bb..6e031999e 100644 --- a/timed_api/tests/__init__.py +++ b/timed_api/tests/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/timed_api/tests/test_activity.py b/timed_api/tests/test_activity.py index be15f9807..a5aad50da 100644 --- a/timed_api/tests/test_activity.py +++ b/timed_api/tests/test_activity.py @@ -1,21 +1,22 @@ -from timed.jsonapi_test_case import JSONAPITestCase -from django.core.urlresolvers import reverse -from timed_api.factories import ActivityFactory, ActivityBlockFactory +"""Tests for the activities endpoint.""" + +from datetime import datetime + from django.contrib.auth.models import User -from datetime import datetime -from pytz import timezone +from django.core.urlresolvers import reverse +from pytz import timezone +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import ActivityBlockFactory, ActivityFactory class ActivityTests(JSONAPITestCase): + """Tests for the activities endpoint.""" def setUp(self): + """Setup the environment for the tests.""" super().setUp() other_user = User.objects.create_user( @@ -37,6 +38,7 @@ def setUp(self): ) def test_activity_list(self): + """Should respond with a list of activities filtered by user.""" url = reverse('activity-list') noauth_res = self.noauth_client.get(url) @@ -50,6 +52,7 @@ def test_activity_list(self): assert len(result['data']) == len(self.activities) def test_activity_detail(self): + """Should respond with a single activity.""" activity = self.activities[0] url = reverse('activity-detail', args=[ @@ -63,6 +66,7 @@ def test_activity_detail(self): assert user_res.status_code == HTTP_200_OK def test_activity_create(self): + """Should create a new activity and automatically set the user.""" task = self.activities[0].task data = { @@ -99,6 +103,7 @@ def test_activity_create(self): ) def test_activity_update(self): + """Should update an existing activity.""" activity = self.activities[0] data = { @@ -129,6 +134,7 @@ def test_activity_update(self): ) def test_activity_delete(self): + """Should delete an activity.""" activity = self.activities[0] url = reverse('activity-detail', args=[ @@ -142,6 +148,7 @@ def test_activity_delete(self): assert user_res.status_code == HTTP_204_NO_CONTENT def test_activity_list_filter_active(self): + """Should respond with a list of active activities.""" activity = self.activities[0] block = ActivityBlockFactory.create(activity=activity) @@ -159,6 +166,7 @@ def test_activity_list_filter_active(self): assert int(result['data'][0]['id']) == int(activity.id) def test_activity_list_filter_day(self): + """Should respond with a list of activities starting today.""" now = datetime.now(timezone('Europe/Zurich')) activity = self.activities[0] diff --git a/timed_api/tests/test_activity_block.py b/timed_api/tests/test_activity_block.py index 3830c6618..2e95c28e0 100644 --- a/timed_api/tests/test_activity_block.py +++ b/timed_api/tests/test_activity_block.py @@ -1,21 +1,22 @@ -from timed.jsonapi_test_case import JSONAPITestCase -from django.core.urlresolvers import reverse -from timed_api.factories import ActivityFactory, ActivityBlockFactory +"""Tests for the activity blocks endpoint.""" + +from datetime import datetime + from django.contrib.auth.models import User -from datetime import datetime -from pytz import timezone +from django.core.urlresolvers import reverse +from pytz import timezone +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import ActivityBlockFactory, ActivityFactory class ActivityBlockTests(JSONAPITestCase): + """Tests for the activity blocks endpoint.""" def setUp(self): + """Setup the environment for the tests.""" super().setUp() other_user = User.objects.create_user( @@ -37,6 +38,7 @@ def setUp(self): ) def test_activity_block_list(self): + """Should respond with a list of activity blocks.""" url = reverse('activity-block-list') noauth_res = self.noauth_client.get(url) @@ -50,6 +52,7 @@ def test_activity_block_list(self): assert len(result['data']) == len(self.activity_blocks) def test_activity_block_detail(self): + """Should respond with a single activity block.""" activity_block = self.activity_blocks[0] url = reverse('activity-block-detail', args=[ @@ -63,6 +66,7 @@ def test_activity_block_detail(self): assert user_res.status_code == HTTP_200_OK def test_activity_block_create(self): + """Should create a new activity block.""" activity = self.activity_blocks[0].activity data = { @@ -95,6 +99,7 @@ def test_activity_block_create(self): assert result['data']['attributes']['to-datetime'] is None def test_activity_block_update(self): + """Should update an existing activity block.""" activity_block = self.activity_blocks[0] tz = timezone('Europe/Zurich') @@ -126,6 +131,7 @@ def test_activity_block_update(self): ) def test_activity_delete(self): + """Should delete an activity block.""" activity_block = self.activity_blocks[0] url = reverse('activity-block-detail', args=[ diff --git a/timed_api/tests/test_attendance.py b/timed_api/tests/test_attendance.py index 19e0cc301..8a26bfb24 100644 --- a/timed_api/tests/test_attendance.py +++ b/timed_api/tests/test_attendance.py @@ -1,20 +1,21 @@ -from timed.jsonapi_test_case import JSONAPITestCase -from django.core.urlresolvers import reverse -from timed_api.factories import AttendanceFactory +"""Tests for the attendances endpoint.""" + +from datetime import timedelta + from django.contrib.auth.models import User -from datetime import timedelta +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import AttendanceFactory class AttendanceTests(JSONAPITestCase): + """Tests for the attendances endpoint.""" def setUp(self): + """Setup the environment for the tests.""" super().setUp() other_user = User.objects.create_user( @@ -33,6 +34,7 @@ def setUp(self): ) def test_attendance_list(self): + """Should respond with a list of attendances filtered by user.""" url = reverse('attendance-list') noauth_res = self.noauth_client.get(url) @@ -46,6 +48,7 @@ def test_attendance_list(self): assert len(result['data']) == len(self.attendances) def test_attendance_detail(self): + """Should respond with a single attendance.""" attendance = self.attendances[0] url = reverse('attendance-detail', args=[ @@ -59,6 +62,7 @@ def test_attendance_detail(self): assert user_res.status_code == HTTP_200_OK def test_attendance_create(self): + """Should create a new attendance and automatically set the user.""" attendance = AttendanceFactory.build() data = { @@ -90,6 +94,7 @@ def test_attendance_create(self): ) def test_attendance_update(self): + """Should update and existing attendance.""" attendance = self.attendances[0] attendance.to_datetime += timedelta(hours=1) @@ -123,6 +128,7 @@ def test_attendance_update(self): ) def test_attendance_delete(self): + """Should delete an attendance.""" attendance = self.attendances[0] url = reverse('attendance-detail', args=[ diff --git a/timed_api/tests/test_customer.py b/timed_api/tests/test_customer.py index 07bba2d43..b7d0c09df 100644 --- a/timed_api/tests/test_customer.py +++ b/timed_api/tests/test_customer.py @@ -1,19 +1,22 @@ -from timed.jsonapi_test_case import JSONAPITestCase +"""Tests for the customers endpoint.""" + from django.core.urlresolvers import reverse -from timed_api.factories import CustomerFactory +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import CustomerFactory class CustomerTests(JSONAPITestCase): + """Tests for the customer endpoint. + + This endpoint should be read only for normal users. + """ def setUp(self): + """Setup the environment for the tests.""" super().setUp() self.customers = CustomerFactory.create_batch(10) @@ -24,6 +27,7 @@ def setUp(self): ) def test_customer_list(self): + """Should respond with a list of customers.""" url = reverse('customer-list') noauth_res = self.noauth_client.get(url) @@ -37,6 +41,7 @@ def test_customer_list(self): assert len(result['data']) == len(self.customers) def test_customer_detail(self): + """Should respond with a single customer.""" customer = self.customers[0] url = reverse('customer-detail', args=[ @@ -50,6 +55,7 @@ def test_customer_detail(self): assert user_res.status_code == HTTP_200_OK def test_customer_create(self): + """Should create a new customer.""" data = { 'data': { 'type': 'customers', @@ -72,6 +78,7 @@ def test_customer_create(self): assert project_admin_res.status_code == HTTP_201_CREATED def test_customer_update(self): + """Should update an existing customer.""" customer = self.customers[0] data = { @@ -104,6 +111,7 @@ def test_customer_update(self): ) def test_customer_delete(self): + """Should delete a customer.""" customer = self.customers[0] url = reverse('customer-detail', args=[ diff --git a/timed_api/tests/test_project.py b/timed_api/tests/test_project.py index 95def83b3..2296ab479 100644 --- a/timed_api/tests/test_project.py +++ b/timed_api/tests/test_project.py @@ -1,20 +1,23 @@ -from timed.jsonapi_test_case import JSONAPITestCase +"""Tests for the projects endpoint.""" + from django.core.urlresolvers import reverse -from timed_api.factories import ProjectFactory, TaskTemplateFactory -from timed_api.models import Task +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import ProjectFactory, TaskTemplateFactory +from timed_api.models import Task class ProjectTests(JSONAPITestCase): + """Tests for the project endpoint. + + This endpoint should be read only for normal users. + """ def setUp(self): + """Setup the environment for the tests.""" super().setUp() self.projects = ProjectFactory.create_batch(10) @@ -25,6 +28,7 @@ def setUp(self): ) def test_project_list(self): + """Should respond with a list of projects.""" url = reverse('project-list') noauth_res = self.noauth_client.get(url) @@ -38,6 +42,7 @@ def test_project_list(self): assert len(result['data']) == len(self.projects) def test_project_detail(self): + """Should respond with a single project.""" project = self.projects[0] url = reverse('project-detail', args=[ @@ -51,6 +56,7 @@ def test_project_detail(self): assert user_res.status_code == HTTP_200_OK def test_project_create(self): + """Should create a new project.""" customer = self.projects[1].customer data = { @@ -89,6 +95,7 @@ def test_project_create(self): ) def test_project_update(self): + """Should update an existing project.""" project = self.projects[0] customer = self.projects[1].customer @@ -135,6 +142,7 @@ def test_project_update(self): ) def test_project_delete(self): + """Should delete a project.""" project = self.projects[0] url = reverse('project-detail', args=[ @@ -150,6 +158,7 @@ def test_project_delete(self): assert project_admin_res.status_code == HTTP_204_NO_CONTENT def test_project_default_tasks(self): + """Should generate tasks based on task templates for a new project.""" templates = TaskTemplateFactory.create_batch(5) project = ProjectFactory.create() tasks = Task.objects.filter(project=project) diff --git a/timed_api/tests/test_report.py b/timed_api/tests/test_report.py index 8fb67c4fe..0b5bac802 100644 --- a/timed_api/tests/test_report.py +++ b/timed_api/tests/test_report.py @@ -1,19 +1,19 @@ -from timed.jsonapi_test_case import JSONAPITestCase -from django.core.urlresolvers import reverse -from timed_api.factories import ReportFactory, TaskFactory +"""Tests for the reports endpoint.""" + from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import ReportFactory, TaskFactory class ReportTests(JSONAPITestCase): + """Tests for the reports endpoint.""" def setUp(self): + """Setup the environment for the tests.""" super().setUp() other_user = User.objects.create_user( @@ -26,6 +26,7 @@ def setUp(self): ReportFactory.create_batch(10, user=other_user) def test_report_list(self): + """Should respond with a list of reports filtered by user.""" url = reverse('report-list') noauth_res = self.noauth_client.get(url) @@ -39,6 +40,7 @@ def test_report_list(self): assert len(result['data']) == len(self.reports) def test_report_detail(self): + """Should respond with a single report.""" report = self.reports[0] url = reverse('report-detail', args=[ @@ -52,6 +54,7 @@ def test_report_detail(self): assert user_res.status_code == HTTP_200_OK def test_report_create(self): + """Should create a new report and automatically set the user.""" task = TaskFactory.create() data = { @@ -94,6 +97,7 @@ def test_report_create(self): ) def test_report_update(self): + """Should update an existing report.""" report = self.reports[0] data = { @@ -137,6 +141,7 @@ def test_report_update(self): assert result['data']['relationships']['task']['data'] is None def test_report_delete(self): + """Should delete a report.""" report = self.reports[0] url = reverse('report-detail', args=[ diff --git a/timed_api/tests/test_task.py b/timed_api/tests/test_task.py index bae1d16f7..1cd54dbd6 100644 --- a/timed_api/tests/test_task.py +++ b/timed_api/tests/test_task.py @@ -1,24 +1,28 @@ -from timed.jsonapi_test_case import JSONAPITestCase +"""Tests for the tasks endpoint.""" + from django.core.urlresolvers import reverse -from timed_api.factories import TaskFactory +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import TaskFactory class TaskTests(JSONAPITestCase): + """Tests for the tasks endpoint. + + This endpoint should be read only for normal users. + """ def setUp(self): + """Setup the environment for the tests.""" super().setUp() self.tasks = TaskFactory.create_batch(5) def test_task_list(self): + """Should respond with a list of tasks.""" url = reverse('task-list') noauth_res = self.noauth_client.get(url) @@ -36,6 +40,7 @@ def test_task_list(self): assert 'project' in result['data'][0]['relationships'] def test_task_detail(self): + """Should respond with a single task.""" task = self.tasks[0] url = reverse('task-detail', args=[ @@ -55,6 +60,7 @@ def test_task_detail(self): assert 'project' in result['data']['relationships'] def test_task_create(self): + """Should create a new task.""" project = self.tasks[0].project data = { @@ -101,6 +107,7 @@ def test_task_create(self): ) def test_task_update(self): + """Should update an exisiting task.""" task = self.tasks[0] project = self.tasks[1].project @@ -146,6 +153,7 @@ def test_task_update(self): ) def test_task_delete(self): + """Should delete a task.""" task = self.tasks[0] url = reverse('task-detail', args=[ diff --git a/timed_api/tests/test_task_template.py b/timed_api/tests/test_task_template.py index 8d56696c8..e03c22bdc 100644 --- a/timed_api/tests/test_task_template.py +++ b/timed_api/tests/test_task_template.py @@ -1,24 +1,28 @@ -from timed.jsonapi_test_case import JSONAPITestCase +"""Tests for the task templates endpoint.""" + from django.core.urlresolvers import reverse -from timed_api.factories import TaskTemplateFactory +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN) -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN -) +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import TaskTemplateFactory class TaskTemplateTests(JSONAPITestCase): + """Tests for the task templates endpoint. + + This endpoint should be read only for normal users and project admins. + """ def setUp(self): + """Setup the environment for the tests.""" super().setUp() self.task_templates = TaskTemplateFactory.create_batch(5) def test_task_template_list(self): + """Should respond with a list of task templates.""" url = reverse('task-template-list') noauth_res = self.noauth_client.get(url) @@ -32,6 +36,7 @@ def test_task_template_list(self): assert len(result['data']) == len(self.task_templates) def test_task_template_detail(self): + """Should respond with a single task template.""" task_template = self.task_templates[0] url = reverse('task-template-detail', args=[ @@ -45,6 +50,7 @@ def test_task_template_detail(self): assert user_res.status_code == HTTP_200_OK def test_task_template_create(self): + """Should create a new task template.""" data = { 'data': { 'type': 'task-templates', @@ -68,6 +74,7 @@ def test_task_template_create(self): assert system_admin_res.status_code == HTTP_201_CREATED def test_task_template_update(self): + """Should update an existing task template.""" task_template = self.task_templates[0] data = { @@ -102,6 +109,7 @@ def test_task_template_update(self): ) def test_task_template_delete(self): + """Should delete a task template.""" task_template = self.task_templates[0] url = reverse('task-template-detail', args=[ diff --git a/timed_api/tests/test_user.py b/timed_api/tests/test_user.py index 5aa78a388..40f3495e9 100644 --- a/timed_api/tests/test_user.py +++ b/timed_api/tests/test_user.py @@ -1,22 +1,27 @@ -from timed.jsonapi_test_case import JSONAPITestCase -from django.core.urlresolvers import reverse -from timed_api.factories import UserFactory +"""Tests for the users endpoint.""" -from rest_framework.status import ( - HTTP_200_OK, - HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN -) +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN) + +from timed.jsonapi_test_case import JSONAPITestCase +from timed_api.factories import UserFactory class UserTests(JSONAPITestCase): + """Tests for the users endpoint. + + This endpoint should be read only. + """ def setUp(self): + """Setup the environment for the tests.""" super().setUp() self.users = UserFactory.create_batch(10) def test_user_list(self): + """Should respond with a list of users.""" url = reverse('user-list') noauth_res = self.noauth_client.get(url) @@ -31,6 +36,7 @@ def test_user_list(self): assert len(result['data']) + len(self.users) + 3 def test_user_detail(self): + """Should respond with a single user.""" user = self.users[0] url = reverse('user-detail', args=[ @@ -44,6 +50,7 @@ def test_user_detail(self): assert user_res.status_code == HTTP_200_OK def test_user_create(self): + """Should not be able to create a user.""" data = {} url = reverse('user-list') @@ -58,6 +65,7 @@ def test_user_create(self): assert system_admin_res.status_code == HTTP_403_FORBIDDEN def test_user_update(self): + """Should not be able to update a user.""" user = self.users[1] data = {} @@ -76,6 +84,7 @@ def test_user_update(self): assert system_admin_res.status_code == HTTP_403_FORBIDDEN def test_user_delete(self): + """Should not be able to delete a user.""" user = self.users[1] data = {} diff --git a/timed_api/urls.py b/timed_api/urls.py index 714831709..64f665010 100644 --- a/timed_api/urls.py +++ b/timed_api/urls.py @@ -1,6 +1,9 @@ -from timed_api import views +"""URL to view mapping for the Timed API.""" + +from django.conf import settings from rest_framework.routers import DefaultRouter -from django.conf import settings + +from timed_api import views r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) diff --git a/timed_api/views.py b/timed_api/views.py index 082f97157..481a7b728 100644 --- a/timed_api/views.py +++ b/timed_api/views.py @@ -1,58 +1,105 @@ -from timed_api import serializers, models, filters -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +"""View sets for the Timed API.""" + from django.contrib.auth.models import User +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet + +from timed_api import filters, models, serializers class UserViewSet(ReadOnlyModelViewSet): + """User view set.""" + queryset = User.objects.all() serializer_class = serializers.UserSerializer filter_class = filters.UserFilterSet class ActivityViewSet(ModelViewSet): + """Activity view set.""" + serializer_class = serializers.ActivitySerializer filter_class = filters.ActivityFilterSet def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered activities + :rtype: QuerySet + """ return models.Activity.objects.filter(user=self.request.user) def perform_create(self, serializer): + """Set the user of the request as user on creation. + + :param ActivitySerializer seralizer: The serializer + """ serializer.save(user=self.request.user) class ActivityBlockViewSet(ModelViewSet): + """Activity view set.""" + serializer_class = serializers.ActivityBlockSerializer filter_class = filters.ActivityBlockFilterSet def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered activity blocks + :rtype: QuerySet + """ return models.ActivityBlock.objects.filter( activity__user=self.request.user ) class AttendanceViewSet(ModelViewSet): + """Attendance view set.""" + serializer_class = serializers.AttendanceSerializer filter_class = filters.AttendanceFilterSet def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered attendances + :rtype: QuerySet + """ return models.Attendance.objects.filter(user=self.request.user) def perform_create(self, serializer): + """Set the user of the request as user on creation. + + :param AttendanceSerializer seralizer: The serializer + """ serializer.save(user=self.request.user) class ReportViewSet(ModelViewSet): + """Report view set.""" + serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered reports + :rtype: QuerySet + """ return models.Report.objects.filter(user=self.request.user) def perform_create(self, serializer): + """Set the user of the request as user on creation. + + :param ReportSerializer seralizer: The serializer + """ serializer.save(user=self.request.user) class CustomerViewSet(ModelViewSet): + """Customer view set.""" + queryset = models.Customer.objects.filter(archived=False) serializer_class = serializers.CustomerSerializer filter_class = filters.CustomerFilterSet @@ -61,6 +108,8 @@ class CustomerViewSet(ModelViewSet): class ProjectViewSet(ModelViewSet): + """Project view set.""" + queryset = models.Project.objects.filter(archived=False) serializer_class = serializers.ProjectSerializer filter_class = filters.ProjectFilterSet @@ -69,12 +118,16 @@ class ProjectViewSet(ModelViewSet): class TaskViewSet(ModelViewSet): + """Task view set.""" + queryset = models.Task.objects.all() serializer_class = serializers.TaskSerializer filter_class = filters.TaskFilterSet class TaskTemplateViewSet(ModelViewSet): + """Task template view set.""" + queryset = models.TaskTemplate.objects.all() serializer_class = serializers.TaskTemplateSerializer filter_class = filters.TaskTemplateFilterSet From 8f756066dfa857283ed2ba4741afd7e5f251547d Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 16:21:34 +0100 Subject: [PATCH 032/980] Added missing requirement --- dev_requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/dev_requirements.txt b/dev_requirements.txt index ae83abf23..3dec01d48 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,5 +1,6 @@ flake8 flake8-docstrings +flake8-isort coverage pytest pytest-django From 7b485f512b20f4126fe873e1a9106fcaba3c4cb8 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 16:37:24 +0100 Subject: [PATCH 033/980] Added various flake8 plugins to enforce our coding style --- .flake8 | 2 ++ dev_requirements.txt | 16 +++++++++++----- requirements.txt | 6 +++--- timed/jsonapi_test_case.py | 4 ++-- timed/wsgi.py | 2 +- timed_api/factories.py | 2 +- timed_api/models.py | 12 ++++++------ timed_api/tests/test_activity.py | 4 ++-- 8 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.flake8 b/.flake8 index 82b15f3f6..d3837d2c5 100644 --- a/.flake8 +++ b/.flake8 @@ -11,3 +11,5 @@ exclude = migrations, docs, __pycache__ + +format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s diff --git a/dev_requirements.txt b/dev_requirements.txt index 3dec01d48..bd0ef602e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,12 +1,18 @@ +coverage +factory-boy flake8 +flake8-blind-except +flake8-colors +flake8-debugger +flake8-deprecated flake8-docstrings flake8-isort -coverage +flake8-quotes +flake8-string-format +ipdb +isort pytest -pytest-django pytest-cov -factory-boy -ipdb +pytest-django sphinx sphinx_rtd_theme -isort diff --git a/requirements.txt b/requirements.txt index c1db4dc05..c53777354 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ django==1.10.4 django-auth-ldap==1.2.8 +django-crispy-forms==1.6.1 +django-filter==1.0.1 +django-jet==1.0.4 djangorestframework==3.5.3 djangorestframework-jsonapi==2.1.1 djangorestframework-jwt==1.9.0 -django-filter==1.0.1 -django-crispy-forms==1.6.1 -django-jet==1.0.4 psycopg2 pytz diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index e69e6b883..9dd987d91 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -98,10 +98,10 @@ def login(self, username, password): response = self.post(reverse('login'), data) if response.status_code != status.HTTP_200_OK: - raise Exception("Wrong credentials!") # pragma: no cover + raise Exception('Wrong credentials!') # pragma: no cover self.credentials( - HTTP_AUTHORIZATION='{} {}'.format( + HTTP_AUTHORIZATION='{0} {1}'.format( api_settings.JWT_AUTH_HEADER_PREFIX, response.data['token'] ) diff --git a/timed/wsgi.py b/timed/wsgi.py index 7e15165e2..587ef8dc7 100644 --- a/timed/wsgi.py +++ b/timed/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timed.settings") +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'timed.settings') application = get_wsgi_application() diff --git a/timed_api/factories.py b/timed_api/factories.py index 6387ca807..3a56d7257 100644 --- a/timed_api/factories.py +++ b/timed_api/factories.py @@ -57,7 +57,7 @@ def username(self): :return: The generated username :rtype: str """ - return '{}.{}'.format( + return '{0}.{1}'.format( self.first_name, self.last_name, ).lower() diff --git a/timed_api/models.py b/timed_api/models.py index 4af0194f9..6feb2ebc6 100644 --- a/timed_api/models.py +++ b/timed_api/models.py @@ -45,7 +45,7 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{}: {}'.format(self.user, self.task) + return '{0}: {1}'.format(self.user, self.task) class Meta: """Meta informations for the activity model.""" @@ -81,7 +81,7 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{} ({})'.format(self.activity, self.duration) + return '{1} ({0})'.format(self.activity, self.duration) class Attendance(models.Model): @@ -103,7 +103,7 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{}: {} - {}'.format( + return '{0}: {1} - {2}'.format( self.user, self.from_datetime.strftime('%d.%m.%Y %h:%i'), self.to_datetime.strftime('%d.%m.%Y %h:%i') @@ -139,7 +139,7 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{}: {}'.format(self.user, self.task) + return '{0}: {1}'.format(self.user, self.task) class Customer(models.Model): @@ -195,7 +195,7 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{} > {}'.format(self.customer, self.name) + return '{0} > {1}'.format(self.customer, self.name) class Task(models.Model): @@ -216,7 +216,7 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{} > {}'.format(self.project, self.name) + return '{0} > {1}'.format(self.project, self.name) class TaskTemplate(models.Model): diff --git a/timed_api/tests/test_activity.py b/timed_api/tests/test_activity.py index a5aad50da..ac8b37045 100644 --- a/timed_api/tests/test_activity.py +++ b/timed_api/tests/test_activity.py @@ -157,7 +157,7 @@ def test_activity_list_filter_active(self): url = reverse('activity-list') - res = self.client.get('{}?active=true'.format(url)) + res = self.client.get('{0}?active=true'.format(url)) result = self.result(res) @@ -175,7 +175,7 @@ def test_activity_list_filter_day(self): url = reverse('activity-list') - res = self.client.get('{}?day={}'.format( + res = self.client.get('{0}?day={1}'.format( url, now.strftime('%Y-%m-%d') )) From ea3d5dfd8508cbf4c50f9bcd924d2f05342c7e56 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 16:38:57 +0100 Subject: [PATCH 034/980] Fixed isort caused import error --- docs/conf.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0027e8b42..a9f9c3e19 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,16 +12,17 @@ # # All configuration values have a default; values that are commented out # serve to show the default. - - -os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' -# documentation root, use os.path.abspath to make it absolute, like shown here. # -import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. + + +import os import sys +os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' + import django sys.path.insert(0, os.path.abspath('..')) From 4b15646df65896c32c1ce6225d81d7e7176411d7 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 17:01:46 +0100 Subject: [PATCH 035/980] Removed flake8 plugin which broke vim syntastic --- .flake8 | 2 -- dev_requirements.txt | 1 - 2 files changed, 3 deletions(-) diff --git a/.flake8 b/.flake8 index d3837d2c5..82b15f3f6 100644 --- a/.flake8 +++ b/.flake8 @@ -11,5 +11,3 @@ exclude = migrations, docs, __pycache__ - -format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s diff --git a/dev_requirements.txt b/dev_requirements.txt index bd0ef602e..37311f5ac 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -2,7 +2,6 @@ coverage factory-boy flake8 flake8-blind-except -flake8-colors flake8-debugger flake8-deprecated flake8-docstrings From 49ecd8e150cd304d4eb4f5c79b4294b887c09cdd Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 17:13:33 +0100 Subject: [PATCH 036/980] Tried fixing travis sphinx deployment --- .travis.yml | 4 ++-- docs/Makefile | 2 +- docs/_static/.gitkeep | 0 docs/_templates/.gitkeep | 0 docs/{ => source}/conf.py | 2 +- docs/{ => source}/index.rst | 0 docs/{ => source}/modules.rst | 0 docs/{ => source}/timed.rst | 0 docs/{ => source}/timed_api.rst | 0 9 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 docs/_static/.gitkeep delete mode 100644 docs/_templates/.gitkeep rename docs/{ => source}/conf.py (99%) rename docs/{ => source}/index.rst (100%) rename docs/{ => source}/modules.rst (100%) rename docs/{ => source}/timed.rst (100%) rename docs/{ => source}/timed_api.rst (100%) diff --git a/.travis.yml b/.travis.yml index 52f50e65b..af9bd4d6c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ cache: pip install: - make install-dev - pip install coveralls - - pip install travis-sphinx + - pip install --user travis-sphinx before_script: - cp timed/config.sample.ini timed/config.ini @@ -19,7 +19,7 @@ before_script: script: - make test - - travis-sphinx --source=docs build + - travis-sphinx build after_success: - coveralls diff --git a/docs/Makefile b/docs/Makefile index 8f918d6a1..a6b588835 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,7 +5,7 @@ SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = Timed -SOURCEDIR = . +SOURCEDIR = source BUILDDIR = _build # Put it first so that "make" without argument is like "make help". diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/_templates/.gitkeep b/docs/_templates/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/conf.py b/docs/source/conf.py similarity index 99% rename from docs/conf.py rename to docs/source/conf.py index a9f9c3e19..c88a20eb0 100644 --- a/docs/conf.py +++ b/docs/source/conf.py @@ -25,7 +25,7 @@ import django -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath('../..')) django.setup() diff --git a/docs/index.rst b/docs/source/index.rst similarity index 100% rename from docs/index.rst rename to docs/source/index.rst diff --git a/docs/modules.rst b/docs/source/modules.rst similarity index 100% rename from docs/modules.rst rename to docs/source/modules.rst diff --git a/docs/timed.rst b/docs/source/timed.rst similarity index 100% rename from docs/timed.rst rename to docs/source/timed.rst diff --git a/docs/timed_api.rst b/docs/source/timed_api.rst similarity index 100% rename from docs/timed_api.rst rename to docs/source/timed_api.rst From 41add83e3c53417721ea29696b8adaebb97a2b23 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 17:16:08 +0100 Subject: [PATCH 037/980] Tried fixing travis ci --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index af9bd4d6c..d16327eed 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: python python: - - "3.5.1" + - "3.5.2" services: - postgresql @@ -11,7 +11,7 @@ cache: pip install: - make install-dev - pip install coveralls - - pip install --user travis-sphinx + - pip install travis-sphinx before_script: - cp timed/config.sample.ini timed/config.ini From c8f75b0221bfe7a97c3872ba5a712a87af8ca64f Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 17:19:18 +0100 Subject: [PATCH 038/980] Added missing folder --- docs/source/_static/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/source/_static/.gitkeep diff --git a/docs/source/_static/.gitkeep b/docs/source/_static/.gitkeep new file mode 100644 index 000000000..e69de29bb From 2ebbcaa6d8cc1ca40e351bb54a39280b3a935920 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 17:35:11 +0100 Subject: [PATCH 039/980] Use rtd for docs --- .travis.yml | 6 +----- docs/source/conf.py | 11 ++++++++--- timed/config.sample.ini | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index d16327eed..b1c7b6b00 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,6 @@ cache: pip install: - make install-dev - pip install coveralls - - pip install travis-sphinx before_script: - cp timed/config.sample.ini timed/config.ini @@ -19,8 +18,5 @@ before_script: script: - make test - - travis-sphinx build -after_success: - - coveralls - - travis-sphinx deploy +after_success: coveralls diff --git a/docs/source/conf.py b/docs/source/conf.py index c88a20eb0..562952b9a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,13 +21,18 @@ import os import sys -os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' - import django -sys.path.insert(0, os.path.abspath('../..')) +on_rtd = os.environ.get('READTHEDOCS') == 'True' + +if on_rtd: + from shutil import copyfile + copyfile('../../timed/config.sample.ini', '../../timed/config.ini') +os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' + +sys.path.insert(0, os.path.abspath('../..')) django.setup() diff --git a/timed/config.sample.ini b/timed/config.sample.ini index 165ffe0fe..345767103 100644 --- a/timed/config.sample.ini +++ b/timed/config.sample.ini @@ -1,7 +1,7 @@ [ldap] AUTH_LDAP_SERVER_URI = ldap://localhost:389 AUTH_LDAP_BIND_DN = uid=Administrator,cn=users,dc=example,dc=com -AUTH_LDAP_PASSWORD = ********** +AUTH_LDAP_PASSWORD = univention AUTH_LDAP_USER_DN_TEMPLATE = uid=%%(user)s,cn=users,dc=example,dc=com [github] From f703b2f1bb5aef13ba2fe80892fad7781a0e88e9 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 5 Jan 2017 17:41:08 +0100 Subject: [PATCH 040/980] Removed useless statement since RTD won't work --- docs/source/conf.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 562952b9a..49fb167eb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,13 +23,6 @@ import django -on_rtd = os.environ.get('READTHEDOCS') == 'True' - -if on_rtd: - from shutil import copyfile - - copyfile('../../timed/config.sample.ini', '../../timed/config.ini') - os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' sys.path.insert(0, os.path.abspath('../..')) From 7f9bcd381a40b74a4f7a5be6755a200df64714ee Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 6 Jan 2017 08:31:53 +0100 Subject: [PATCH 041/980] Simplify travis config --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index b1c7b6b00..57bd5a227 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,6 @@ before_script: - cp timed/config.sample.ini timed/config.ini - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres -script: - - make test +script: make test after_success: coveralls From 7e73a53c1723ce4fb18ff4743cbd1648606f076a Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 6 Jan 2017 08:35:26 +0100 Subject: [PATCH 042/980] Update readme --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3fc42a952..18ed0de8f 100644 --- a/README.md +++ b/README.md @@ -7,22 +7,23 @@ Timed timetracking software REST API built with Django ## Installation **Requirements** -* Python 3.5.1 +* python 3.5.2 * docker * docker-compose After installing and configuring those requirements, you should be able to run the following commands to complete the installation: ```bash -$ make install # Install Python requirements -$ docker-compose up -d # Start the containers -$ make setup-ldap # Configure UCS LDAP container -$ make create-ldap-user # Create a new standard user -$ ./manage.py migrate # Run Django migrations -$ ./manage.py createsuperuser # Create a new Django superuser +$ make install # Install Python requirements +$ cp timed/config.sample.ini timed/config.ini # Use the sample config file +$ docker-compose up -d # Start the containers +$ make setup-ldap # Configure UCS LDAP container +$ make create-ldap-user # Create a new standard user +$ ./manage.py migrate # Run Django migrations +$ ./manage.py createsuperuser # Create a new Django superuser ``` -You can now access the API at http://localhost:8000/api/v1 and the admin panel at http://localhost:8000/admin/ +You can now access the API at http://localhost:8000/api/v1 and the admin interface at http://localhost:8000/admin/ ## Testing Run tests by executing `make test` From 930dd226d664eab09fbd31641b7560ef59a68959 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 6 Jan 2017 08:36:57 +0100 Subject: [PATCH 043/980] Do not write the full path of a type in the docstrings --- timed_api/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed_api/admin.py b/timed_api/admin.py index 9c81ec42f..77ca7241b 100644 --- a/timed_api/admin.py +++ b/timed_api/admin.py @@ -15,7 +15,7 @@ def get_queryset(self, request): :param django.http.Request request: The HTTP request for this view :return: The filtered queryset - :rtype: django.db.models.query.QuerySet + :rtype: QuerySet """ qs = super().get_queryset(request) From ce3ad3737545d1efc86146e1aa385a3dcf322240 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 9 Feb 2017 11:07:40 +0100 Subject: [PATCH 044/980] Use global version for setup.py and docs --- docs/source/conf.py | 6 ++++-- setup.py | 4 +++- timed/__init__.py | 2 ++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 49fb167eb..a7a35f14f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,6 +23,8 @@ import django +from timed import __version__ + os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' sys.path.insert(0, os.path.abspath('../..')) @@ -62,9 +64,9 @@ # built documents. # # The short X.Y version. -version = '0.0.0' +version = __version__ # The full version, including alpha/beta/rc tags. -release = '0.0.0' +release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index f82292414..fff53dd52 100644 --- a/setup.py +++ b/setup.py @@ -4,12 +4,14 @@ from setuptools import setup +from timed import __version__ + with codecs.open('README.md', 'r', encoding='UTF-8') as f: README_TEXT = f.read() setup( name='timed', - version='0.0.0', + version=__version__, author='Adfinis SyGroup AG', author_email='https://adfinis-sygroup.ch/', description='Timetracking software', diff --git a/timed/__init__.py b/timed/__init__.py index 6e031999e..fc1abb65b 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1,3 @@ # noqa: D104 + +__version__ = '0.0.0' From 96ba5a00bac06f7686e7d554cf02b34a78e8cebd Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 9 Feb 2017 12:29:51 +0100 Subject: [PATCH 045/980] Splitted the timed_api app into three apps. --- employment/__init__.py | 3 + employment/apps.py | 9 + employment/factories.py | 31 ++ employment/filters.py | 14 + .../migrations/__init__.py | 0 employment/serializers.py | 37 +++ {timed_api => employment}/tests/__init__.py | 0 {timed_api => employment}/tests/test_user.py | 2 +- employment/urls.py | 12 + employment/views.py | 14 + projects/__init__.py | 3 + projects/admin.py | 36 +++ projects/apps.py | 9 + projects/factories.py | 60 ++++ projects/filters.py | 35 +++ projects/migrations/0001_initial.py | 61 ++++ projects/migrations/__init__.py | 0 projects/models.py | 112 +++++++ projects/serializers.py | 89 ++++++ projects/tests/__init__.py | 1 + .../tests/test_customer.py | 2 +- {timed_api => projects}/tests/test_project.py | 4 +- {timed_api => projects}/tests/test_task.py | 2 +- projects/urls.py | 14 + projects/views.py | 33 +++ timed/settings.py | 3 +- timed/urls.py | 4 +- timed_api/__init__.py | 3 - timed_api/apps.py | 10 - timed_api/migrations/0001_initial.py | 117 -------- .../migrations/0002_auto_20170104_0932.py | 25 -- timed_api/models.py | 248 ---------------- timed_api/serializers.py | 273 ------------------ timed_api/tests/test_task_template.py | 127 -------- tracking/__init__.py | 3 + {timed_api => tracking}/admin.py | 33 +-- tracking/apps.py | 9 + {timed_api => tracking}/factories.py | 95 +----- {timed_api => tracking}/filters.py | 55 +--- tracking/migrations/0001_initial.py | 63 ++++ tracking/migrations/__init__.py | 0 tracking/models.py | 134 +++++++++ tracking/serializers.py | 101 +++++++ tracking/tests/__init__.py | 1 + .../tests/test_activity.py | 2 +- .../tests/test_activity_block.py | 2 +- .../tests/test_attendance.py | 2 +- {timed_api => tracking}/tests/test_report.py | 3 +- {timed_api => tracking}/urls.py | 10 +- {timed_api => tracking}/views.py | 51 +--- 50 files changed, 915 insertions(+), 1042 deletions(-) create mode 100644 employment/__init__.py create mode 100644 employment/apps.py create mode 100644 employment/factories.py create mode 100644 employment/filters.py rename {timed_api => employment}/migrations/__init__.py (100%) create mode 100644 employment/serializers.py rename {timed_api => employment}/tests/__init__.py (100%) rename {timed_api => employment}/tests/test_user.py (98%) create mode 100644 employment/urls.py create mode 100644 employment/views.py create mode 100644 projects/__init__.py create mode 100644 projects/admin.py create mode 100644 projects/apps.py create mode 100644 projects/factories.py create mode 100644 projects/filters.py create mode 100644 projects/migrations/0001_initial.py create mode 100644 projects/migrations/__init__.py create mode 100644 projects/models.py create mode 100644 projects/serializers.py create mode 100644 projects/tests/__init__.py rename {timed_api => projects}/tests/test_customer.py (98%) rename {timed_api => projects}/tests/test_project.py (98%) rename {timed_api => projects}/tests/test_task.py (99%) create mode 100644 projects/urls.py create mode 100644 projects/views.py delete mode 100644 timed_api/__init__.py delete mode 100644 timed_api/apps.py delete mode 100644 timed_api/migrations/0001_initial.py delete mode 100644 timed_api/migrations/0002_auto_20170104_0932.py delete mode 100644 timed_api/models.py delete mode 100644 timed_api/serializers.py delete mode 100644 timed_api/tests/test_task_template.py create mode 100644 tracking/__init__.py rename {timed_api => tracking}/admin.py (69%) create mode 100644 tracking/apps.py rename {timed_api => tracking}/factories.py (60%) rename {timed_api => tracking}/filters.py (70%) create mode 100644 tracking/migrations/0001_initial.py create mode 100644 tracking/migrations/__init__.py create mode 100644 tracking/models.py create mode 100644 tracking/serializers.py create mode 100644 tracking/tests/__init__.py rename {timed_api => tracking}/tests/test_activity.py (98%) rename {timed_api => tracking}/tests/test_activity_block.py (98%) rename {timed_api => tracking}/tests/test_attendance.py (98%) rename {timed_api => tracking}/tests/test_report.py (98%) rename {timed_api => tracking}/urls.py (51%) rename {timed_api => tracking}/views.py (61%) diff --git a/employment/__init__.py b/employment/__init__.py new file mode 100644 index 000000000..92b2df23a --- /dev/null +++ b/employment/__init__.py @@ -0,0 +1,3 @@ +# noqa: D104 + +default_app_config = 'employment.apps.EmploymentConfig' diff --git a/employment/apps.py b/employment/apps.py new file mode 100644 index 000000000..23ac943c1 --- /dev/null +++ b/employment/apps.py @@ -0,0 +1,9 @@ +"""Configuration for employment app.""" + +from django.apps import AppConfig + + +class EmploymentConfig(AppConfig): + """App configuration for employment app.""" + + name = 'employment' diff --git a/employment/factories.py b/employment/factories.py new file mode 100644 index 000000000..d9146679a --- /dev/null +++ b/employment/factories.py @@ -0,0 +1,31 @@ +"""Factories for testing the tracking app.""" + +from django.conf import settings +from factory import Faker, lazy_attribute +from factory.django import DjangoModelFactory + + +class UserFactory(DjangoModelFactory): + """User factory.""" + + first_name = Faker('first_name') + last_name = Faker('last_name') + email = Faker('email') + password = Faker('password', length=12) + + @lazy_attribute + def username(self): + """Generate a username from first and last name. + + :return: The generated username + :rtype: str + """ + return '{0}.{1}'.format( + self.first_name, + self.last_name, + ).lower() + + class Meta: + """Meta informations for the user factory.""" + + model = settings.AUTH_USER_MODEL diff --git a/employment/filters.py b/employment/filters.py new file mode 100644 index 000000000..d3b6bd600 --- /dev/null +++ b/employment/filters.py @@ -0,0 +1,14 @@ +"""Filters for filtering the data of the employment app endpoints.""" + +from django.contrib.auth.models import User +from django_filters import FilterSet + + +class UserFilterSet(FilterSet): + """Filter set for the users endpoint.""" + + class Meta: + """Meta information for the user filter set.""" + + model = User + fields = [] diff --git a/timed_api/migrations/__init__.py b/employment/migrations/__init__.py similarity index 100% rename from timed_api/migrations/__init__.py rename to employment/migrations/__init__.py diff --git a/employment/serializers.py b/employment/serializers.py new file mode 100644 index 000000000..9d584c5e0 --- /dev/null +++ b/employment/serializers.py @@ -0,0 +1,37 @@ +"""Serializers for the employment app.""" + +from django.contrib.auth.models import User +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import ModelSerializer + + +class UserSerializer(ModelSerializer): + """User serializer.""" + + projects = ResourceRelatedField(read_only=True, + many=True) + attendances = ResourceRelatedField(read_only=True, + many=True) + activities = ResourceRelatedField(read_only=True, + many=True) + reports = ResourceRelatedField(read_only=True, + many=True) + + included_serializers = { + 'projects': 'projects.serializers.ProjectSerializer' + } + + class Meta: + """Meta information for the user serializer.""" + + model = User + fields = [ + 'username', + 'first_name', + 'last_name', + 'email', + 'projects', + 'attendances', + 'activities', + 'reports', + ] diff --git a/timed_api/tests/__init__.py b/employment/tests/__init__.py similarity index 100% rename from timed_api/tests/__init__.py rename to employment/tests/__init__.py diff --git a/timed_api/tests/test_user.py b/employment/tests/test_user.py similarity index 98% rename from timed_api/tests/test_user.py rename to employment/tests/test_user.py index 40f3495e9..5d45b6d3e 100644 --- a/timed_api/tests/test_user.py +++ b/employment/tests/test_user.py @@ -4,8 +4,8 @@ from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) +from employment.factories import UserFactory from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import UserFactory class UserTests(JSONAPITestCase): diff --git a/employment/urls.py b/employment/urls.py new file mode 100644 index 000000000..cd3f3eb8b --- /dev/null +++ b/employment/urls.py @@ -0,0 +1,12 @@ +"""URL to view mapping for the employment app.""" + +from django.conf import settings +from rest_framework.routers import DefaultRouter + +from employment import views + +r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) + +r.register(r'users', views.UserViewSet, 'user') + +urlpatterns = r.urls diff --git a/employment/views.py b/employment/views.py new file mode 100644 index 000000000..487a86b34 --- /dev/null +++ b/employment/views.py @@ -0,0 +1,14 @@ +"""Viewsets for the employment app.""" + +from django.contrib.auth.models import User +from rest_framework.viewsets import ReadOnlyModelViewSet + +from employment import filters, serializers + + +class UserViewSet(ReadOnlyModelViewSet): + """User view set.""" + + queryset = User.objects.all() + serializer_class = serializers.UserSerializer + filter_class = filters.UserFilterSet diff --git a/projects/__init__.py b/projects/__init__.py new file mode 100644 index 000000000..74a996408 --- /dev/null +++ b/projects/__init__.py @@ -0,0 +1,3 @@ +# noqa: D104 + +default_app_config = 'projects.apps.ProjectsConfig' diff --git a/projects/admin.py b/projects/admin.py new file mode 100644 index 000000000..067776dd3 --- /dev/null +++ b/projects/admin.py @@ -0,0 +1,36 @@ +"""Views for the admin interface.""" + +from django.contrib import admin + +from projects import models + + +@admin.register(models.Customer) +class CustomerAdmin(admin.ModelAdmin): + """Customer admin view.""" + + list_display = ['name'] + search_fields = ['name'] + + +@admin.register(models.Project) +class ProjectAdmin(admin.ModelAdmin): + """Project admin view.""" + + list_display = ['name', 'customer'] + list_filter = ['customer'] + search_fields = ['name', 'customer'] + + +@admin.register(models.Task) +class TaskAdmin(admin.ModelAdmin): + """Task admin view.""" + + list_display = ['__str__'] + + +@admin.register(models.TaskTemplate) +class TaskTemplateAdmin(admin.ModelAdmin): + """Task template admin view.""" + + list_display = ['name'] diff --git a/projects/apps.py b/projects/apps.py new file mode 100644 index 000000000..6eff0fcdc --- /dev/null +++ b/projects/apps.py @@ -0,0 +1,9 @@ +"""Configuration for projects app.""" + +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + """App configuration for projects app.""" + + name = 'projects' diff --git a/projects/factories.py b/projects/factories.py new file mode 100644 index 000000000..d522ba066 --- /dev/null +++ b/projects/factories.py @@ -0,0 +1,60 @@ +"""Factories for testing the projects app.""" + +from factory import Faker, SubFactory +from factory.django import DjangoModelFactory + +from projects import models + + +class CustomerFactory(DjangoModelFactory): + """Customer factory.""" + + name = Faker('company') + email = Faker('company_email') + website = Faker('url') + comment = Faker('sentence') + archived = False + + class Meta: + """Meta informations for the customer factory.""" + + model = models.Customer + + +class ProjectFactory(DjangoModelFactory): + """Project factory.""" + + name = Faker('catch_phrase') + archived = False + comment = Faker('sentence') + customer = SubFactory('projects.factories.CustomerFactory') + + class Meta: + """Meta informations for the project factory.""" + + model = models.Project + + +class TaskFactory(DjangoModelFactory): + """Task factory.""" + + name = Faker('company_suffix') + estimated_hours = Faker('random_int', min=0, max=2000) + archived = False + project = SubFactory('projects.factories.ProjectFactory') + + class Meta: + """Meta informations for the task factory.""" + + model = models.Task + + +class TaskTemplateFactory(DjangoModelFactory): + """Task template factory.""" + + name = Faker('sentence') + + class Meta: + """Meta informations for the task template factory.""" + + model = models.TaskTemplate diff --git a/projects/filters.py b/projects/filters.py new file mode 100644 index 000000000..f2023c103 --- /dev/null +++ b/projects/filters.py @@ -0,0 +1,35 @@ +"""Filters for filtering the data of the projects app endpoints.""" + +from django_filters import FilterSet + +from projects import models + + +class CustomerFilterSet(FilterSet): + """Filter set for the customers endpoint.""" + + class Meta: + """Meta information for the customer filter set.""" + + model = models.Customer + fields = ['archived'] + + +class ProjectFilterSet(FilterSet): + """Filter set for the projects endpoint.""" + + class Meta: + """Meta information for the project filter set.""" + + model = models.Project + fields = ['archived', 'customer'] + + +class TaskFilterSet(FilterSet): + """Filter set for the tasks endpoint.""" + + class Meta: + """Meta information for the task filter set.""" + + model = models.Task + fields = ['archived', 'project'] diff --git a/projects/migrations/0001_initial.py b/projects/migrations/0001_initial.py new file mode 100644 index 000000000..5a3886717 --- /dev/null +++ b/projects/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-09 11:27 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('email', models.EmailField(blank=True, max_length=254)), + ('website', models.URLField(blank=True)), + ('comment', models.TextField(blank=True)), + ('archived', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('comment', models.TextField(blank=True)), + ('archived', models.BooleanField(default=False)), + ('tracker_type', models.CharField(blank=True, choices=[('GH', 'Github'), ('RM', 'Redmine')], max_length=2)), + ('tracker_name', models.CharField(blank=True, max_length=255)), + ('tracker_api_key', models.CharField(blank=True, max_length=255)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.Customer')), + ('leaders', models.ManyToManyField(blank=True, related_name='projects', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('estimated_hours', models.PositiveIntegerField(blank=True, null=True)), + ('archived', models.BooleanField(default=False)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.Project')), + ], + ), + migrations.CreateModel( + name='TaskTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ], + ), + ] diff --git a/projects/migrations/__init__.py b/projects/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/projects/models.py b/projects/models.py new file mode 100644 index 000000000..1d08f67e7 --- /dev/null +++ b/projects/models.py @@ -0,0 +1,112 @@ +"""Models for the projects app.""" + +from django.conf import settings +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver + + +class Customer(models.Model): + """Customer model. + + A customer is a person or company which will pay the work + reported on their projects. + """ + + name = models.CharField(max_length=255) + email = models.EmailField(blank=True) + website = models.URLField(blank=True) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return self.name + + +class Project(models.Model): + """Project model. + + A project is an offer in most cases. It has multiple tasks and + belongs to a customer. + """ + + TYPES = ( + ('GH', 'Github'), + ('RM', 'Redmine') + ) + + name = models.CharField(max_length=255) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) + tracker_type = models.CharField(max_length=2, choices=TYPES, blank=True) + tracker_name = models.CharField(max_length=255, blank=True) + tracker_api_key = models.CharField(max_length=255, blank=True) + customer = models.ForeignKey('projects.Customer', + related_name='projects') + leaders = models.ManyToManyField(settings.AUTH_USER_MODEL, + related_name='projects', + blank=True) + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{0} > {1}'.format(self.customer, self.name) + + +class Task(models.Model): + """Task model. + + A task is a certain activity type on a project. Users can + report their activities and reports on it. + """ + + name = models.CharField(max_length=255) + estimated_hours = models.PositiveIntegerField(blank=True, null=True) + archived = models.BooleanField(default=False) + project = models.ForeignKey('projects.Project', + related_name='tasks') + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{0} > {1}'.format(self.project, self.name) + + +class TaskTemplate(models.Model): + """Task template model. + + A task template is a global template of a task which should + be generated for every project. + """ + + name = models.CharField(max_length=255) + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return self.name + + +@receiver(post_save, sender=Project) +def create_default_tasks(sender, instance, created, **kwargs): + """Create default tasks on a project. + + This gets executed as soon as a project is created. + """ + if created: + for template in TaskTemplate.objects.all(): + Task.objects.create(name=template.name, project=instance) diff --git a/projects/serializers.py b/projects/serializers.py new file mode 100644 index 000000000..2cf5ef769 --- /dev/null +++ b/projects/serializers.py @@ -0,0 +1,89 @@ +"""Serializers for the projects app.""" + +from django.contrib.auth.models import User +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import ModelSerializer + +from projects import models + + +class CustomerSerializer(ModelSerializer): + """Customer serializer.""" + + projects = ResourceRelatedField(read_only=True, + many=True) + + included_serializers = { + 'projects': 'projects.serializers.ProjectSerializer' + } + + class Meta: + """Meta information for the customer serializer.""" + + model = models.Customer + fields = [ + 'name', + 'email', + 'website', + 'comment', + 'archived', + 'projects', + ] + + +class ProjectSerializer(ModelSerializer): + """Project serializer.""" + + customer = ResourceRelatedField(queryset=models.Customer.objects.all()) + leaders = ResourceRelatedField(queryset=User.objects.all(), + required=False, + many=True) + tasks = ResourceRelatedField(read_only=True, + many=True) + + included_serializers = { + 'customer': 'projects.serializers.CustomerSerializer', + 'leaders': 'employment.serializers.UserSerializer', + 'tasks': 'projects.serializers.TaskSerializer' + } + + class Meta: + """Meta information for the project serializer.""" + + model = models.Project + fields = [ + 'name', + 'comment', + 'archived', + 'tracker_type', + 'tracker_name', + 'tracker_api_key', + 'customer', + 'leaders', + 'tasks', + ] + + +class TaskSerializer(ModelSerializer): + """Task serializer.""" + + activities = ResourceRelatedField(read_only=True, + many=True) + project = ResourceRelatedField(queryset=models.Project.objects.all()) + + included_serializers = { + 'activities': 'tracking.serializers.ActivitySerializer', + 'project': 'projects.serializers.ProjectSerializer' + } + + class Meta: + """Meta information for the task serializer.""" + + model = models.Task + fields = [ + 'name', + 'estimated_hours', + 'archived', + 'project', + 'activities', + ] diff --git a/projects/tests/__init__.py b/projects/tests/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/projects/tests/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/timed_api/tests/test_customer.py b/projects/tests/test_customer.py similarity index 98% rename from timed_api/tests/test_customer.py rename to projects/tests/test_customer.py index b7d0c09df..a84365663 100644 --- a/timed_api/tests/test_customer.py +++ b/projects/tests/test_customer.py @@ -5,8 +5,8 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) +from projects.factories import CustomerFactory from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import CustomerFactory class CustomerTests(JSONAPITestCase): diff --git a/timed_api/tests/test_project.py b/projects/tests/test_project.py similarity index 98% rename from timed_api/tests/test_project.py rename to projects/tests/test_project.py index 2296ab479..49b621565 100644 --- a/timed_api/tests/test_project.py +++ b/projects/tests/test_project.py @@ -5,9 +5,9 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) +from projects.factories import ProjectFactory, TaskTemplateFactory +from projects.models import Task from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import ProjectFactory, TaskTemplateFactory -from timed_api.models import Task class ProjectTests(JSONAPITestCase): diff --git a/timed_api/tests/test_task.py b/projects/tests/test_task.py similarity index 99% rename from timed_api/tests/test_task.py rename to projects/tests/test_task.py index 1cd54dbd6..7a6aa2abe 100644 --- a/timed_api/tests/test_task.py +++ b/projects/tests/test_task.py @@ -5,8 +5,8 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) +from projects.factories import TaskFactory from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import TaskFactory class TaskTests(JSONAPITestCase): diff --git a/projects/urls.py b/projects/urls.py new file mode 100644 index 000000000..37b94a257 --- /dev/null +++ b/projects/urls.py @@ -0,0 +1,14 @@ +"""URL to view mapping for the projects app.""" + +from django.conf import settings +from rest_framework.routers import DefaultRouter + +from projects import views + +r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) + +r.register(r'projects', views.ProjectViewSet, 'project') +r.register(r'customers', views.CustomerViewSet, 'customer') +r.register(r'tasks', views.TaskViewSet, 'task') + +urlpatterns = r.urls diff --git a/projects/views.py b/projects/views.py new file mode 100644 index 000000000..7a51a91bc --- /dev/null +++ b/projects/views.py @@ -0,0 +1,33 @@ +"""Viewsets for the projects app.""" + +from rest_framework.viewsets import ModelViewSet + +from projects import filters, models, serializers + + +class CustomerViewSet(ModelViewSet): + """Customer view set.""" + + queryset = models.Customer.objects.filter(archived=False) + serializer_class = serializers.CustomerSerializer + filter_class = filters.CustomerFilterSet + search_fields = ('name',) + ordering = 'name' + + +class ProjectViewSet(ModelViewSet): + """Project view set.""" + + queryset = models.Project.objects.filter(archived=False) + serializer_class = serializers.ProjectSerializer + filter_class = filters.ProjectFilterSet + search_fields = ('name', 'customer__name',) + ordering = ('customer__name', 'name') + + +class TaskViewSet(ModelViewSet): + """Task view set.""" + + queryset = models.Task.objects.all() + serializer_class = serializers.TaskSerializer + filter_class = filters.TaskFilterSet diff --git a/timed/settings.py b/timed/settings.py index d59e59314..e704bbf40 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -54,7 +54,8 @@ def trueish(value): 'django.contrib.staticfiles', 'rest_framework', 'crispy_forms', - 'timed_api', + 'projects', + 'tracking', ] MIDDLEWARE_CLASSES = [ diff --git a/timed/urls.py b/timed/urls.py index 2100e71d0..9956faed0 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -9,5 +9,7 @@ url(r'^admin/', admin.site.urls), url(r'^api/v1/auth/login', obtain_jwt_token, name='login'), url(r'^api/v1/auth/refresh', refresh_jwt_token, name='refresh'), - url(r'^api/v1/', include('timed_api.urls')) + url(r'^api/v1/', include('employment.urls')), + url(r'^api/v1/', include('projects.urls')), + url(r'^api/v1/', include('tracking.urls')) ] diff --git a/timed_api/__init__.py b/timed_api/__init__.py deleted file mode 100644 index adcfb92ed..000000000 --- a/timed_api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# noqa: D104 - -default_app_config = 'timed_api.apps.TimedAPIConfig' diff --git a/timed_api/apps.py b/timed_api/apps.py deleted file mode 100644 index a61173750..000000000 --- a/timed_api/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Configuration for this app.""" - -from django.apps import AppConfig - - -class TimedAPIConfig(AppConfig): - """App configuration for Timed API.""" - - name = 'timed_api' - verbose_name = 'API' diff --git a/timed_api/migrations/0001_initial.py b/timed_api/migrations/0001_initial.py deleted file mode 100644 index 78e641c99..000000000 --- a/timed_api/migrations/0001_initial.py +++ /dev/null @@ -1,117 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2016-08-11 13:19 -from __future__ import unicode_literals - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Activity', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('comment', models.CharField(blank=True, max_length=255)), - ('start_datetime', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='ActivityBlock', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('from_datetime', models.DateTimeField(auto_now_add=True)), - ('to_datetime', models.DateTimeField(blank=True, null=True)), - ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='timed_api.Activity')), - ], - ), - migrations.CreateModel( - name='Attendance', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('from_datetime', models.DateTimeField()), - ('to_datetime', models.DateTimeField()), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='Customer', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('email', models.EmailField(blank=True, max_length=254)), - ('website', models.URLField(blank=True)), - ('comment', models.TextField(blank=True)), - ('archived', models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name='Project', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('comment', models.TextField(blank=True)), - ('archived', models.BooleanField(default=False)), - ('tracker_type', models.CharField(blank=True, choices=[('GH', 'Github'), ('RM', 'Redmine')], max_length=2)), - ('tracker_name', models.CharField(blank=True, max_length=255)), - ('tracker_api_key', models.CharField(blank=True, max_length=255)), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='timed_api.Customer')), - ('leaders', models.ManyToManyField(blank=True, related_name='projects', to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='Report', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('comment', models.CharField(max_length=255)), - ('duration', models.DurationField()), - ('review', models.BooleanField(default=False)), - ('nta', models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name='Task', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('estimated_hours', models.PositiveIntegerField(blank=True, null=True)), - ('archived', models.BooleanField(default=False)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='timed_api.Project')), - ], - ), - migrations.CreateModel( - name='TaskTemplate', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ], - ), - migrations.AddField( - model_name='report', - name='task', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='timed_api.Task'), - ), - migrations.AddField( - model_name='report', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='activity', - name='task', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='timed_api.Task'), - ), - migrations.AddField( - model_name='activity', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/timed_api/migrations/0002_auto_20170104_0932.py b/timed_api/migrations/0002_auto_20170104_0932.py deleted file mode 100644 index 77a541583..000000000 --- a/timed_api/migrations/0002_auto_20170104_0932.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-01-04 08:32 -from __future__ import unicode_literals - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('timed_api', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='activity', - options={'verbose_name_plural': 'activities'}, - ), - migrations.AlterField( - model_name='report', - name='task', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='timed_api.Task'), - ), - ] diff --git a/timed_api/models.py b/timed_api/models.py deleted file mode 100644 index 6feb2ebc6..000000000 --- a/timed_api/models.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Models for the Timed API.""" - -from datetime import timedelta - -from django.conf import settings -from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver - - -class Activity(models.Model): - """Activity model. - - An activity represents multiple timeblocks in which a user worked on a - certain task. - """ - - comment = models.CharField(max_length=255, blank=True) - start_datetime = models.DateTimeField(auto_now_add=True) - task = models.ForeignKey('Task', related_name='activities') - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - related_name='activities' - ) - - @property - def duration(self): - """The total duration of this activity. - - :return: The total duration - :rtype: datetime.timedelta - """ - durations = [ - block.duration - for block - in self.blocks.all() - if block.duration - ] - - return sum(durations, timedelta()) - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return '{0}: {1}'.format(self.user, self.task) - - class Meta: - """Meta informations for the activity model.""" - - verbose_name_plural = 'activities' - - -class ActivityBlock(models.Model): - """Activity block model. - - An activity block is a timeblock of an activity. - """ - - activity = models.ForeignKey('Activity', related_name='blocks') - from_datetime = models.DateTimeField(auto_now_add=True) - to_datetime = models.DateTimeField(blank=True, null=True) - - @property - def duration(self): - """The duration of this activity block. - - :return: The duration - :rtype: datetime.timedelta or None - """ - if not self.to_datetime: - return None - - return self.to_datetime - self.from_datetime - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return '{1} ({0})'.format(self.activity, self.duration) - - -class Attendance(models.Model): - """Attendance model. - - An attendance is a timespan in which a user was present at work. - """ - - from_datetime = models.DateTimeField() - to_datetime = models.DateTimeField() - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - related_name='attendances' - ) - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return '{0}: {1} - {2}'.format( - self.user, - self.from_datetime.strftime('%d.%m.%Y %h:%i'), - self.to_datetime.strftime('%d.%m.%Y %h:%i') - ) - - -class Report(models.Model): - """Report model. - - A report is a timespan in which a user worked on a certain task. - The difference to the activity is, that this is going to be on the - bill for the customer. - """ - - comment = models.CharField(max_length=255) - duration = models.DurationField() - review = models.BooleanField(default=False) - nta = models.BooleanField(default=False) - task = models.ForeignKey( - 'Task', - null=True, - blank=True, - related_name='reports' - ) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - related_name='reports' - ) - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return '{0}: {1}'.format(self.user, self.task) - - -class Customer(models.Model): - """Customer model. - - A customer is a person or company which will pay the work - reported on their projects. - """ - - name = models.CharField(max_length=255) - email = models.EmailField(blank=True) - website = models.URLField(blank=True) - comment = models.TextField(blank=True) - archived = models.BooleanField(default=False) - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return self.name - - -class Project(models.Model): - """Project model. - - A project is an offer in most cases. It has multiple tasks and - belongs to a customer. - """ - - TYPES = ( - ('GH', 'Github'), - ('RM', 'Redmine') - ) - - name = models.CharField(max_length=255) - comment = models.TextField(blank=True) - archived = models.BooleanField(default=False) - tracker_type = models.CharField(max_length=2, choices=TYPES, blank=True) - tracker_name = models.CharField(max_length=255, blank=True) - tracker_api_key = models.CharField(max_length=255, blank=True) - customer = models.ForeignKey('Customer', related_name='projects') - leaders = models.ManyToManyField( - settings.AUTH_USER_MODEL, - related_name='projects', - blank=True - ) - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return '{0} > {1}'.format(self.customer, self.name) - - -class Task(models.Model): - """Task model. - - A task is a certain activity type on a project. Users can - report their activities and reports on it. - """ - - name = models.CharField(max_length=255) - estimated_hours = models.PositiveIntegerField(blank=True, null=True) - archived = models.BooleanField(default=False) - project = models.ForeignKey('Project', related_name='tasks') - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return '{0} > {1}'.format(self.project, self.name) - - -class TaskTemplate(models.Model): - """Task template model. - - A task template is a global template of a task which should - be generated for every project. - """ - - name = models.CharField(max_length=255) - - def __str__(self): - """String representation. - - :return: The string representation - :rtype: str - """ - return self.name - - -@receiver(post_save, sender=Project) -def create_default_tasks(sender, instance, created, **kwargs): - """Create default tasks on a project. - - This gets executed as soon as a project is created. - """ - if created: - for template in TaskTemplate.objects.all(): - Task.objects.create(name=template.name, project=instance) diff --git a/timed_api/serializers.py b/timed_api/serializers.py deleted file mode 100644 index aeeb67f08..000000000 --- a/timed_api/serializers.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Serializers for the Timed API.""" - -from django.contrib.auth.models import User -from rest_framework_json_api import serializers -from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import ModelSerializer - -from timed_api import models - - -class UserSerializer(ModelSerializer): - """User serializer.""" - - projects = ResourceRelatedField( - read_only=True, - many=True - ) - - attendances = ResourceRelatedField( - read_only=True, - many=True - ) - - activities = ResourceRelatedField( - read_only=True, - many=True - ) - - reports = ResourceRelatedField( - read_only=True, - many=True - ) - - included_serializers = { - 'projects': 'timed_api.serializers.ProjectSerializer' - } - - class Meta: - """Meta information for the user serializer.""" - - model = User - fields = [ - 'username', - 'first_name', - 'last_name', - 'email', - 'projects', - 'attendances', - 'activities', - 'reports', - ] - - -class ActivitySerializer(ModelSerializer): - """Activity serializer.""" - - duration = serializers.DurationField(read_only=True) - - task = ResourceRelatedField( - queryset=models.Task.objects.all() - ) - - user = ResourceRelatedField( - read_only=True - ) - - blocks = ResourceRelatedField( - read_only=True, - many=True, - ) - - included_serializers = { - 'blocks': 'timed_api.serializers.ActivityBlockSerializer', - 'task': 'timed_api.serializers.TaskSerializer', - 'user': 'timed_api.serializers.UserSerializer' - } - - class Meta: - """Meta information for the activity serializer.""" - - model = models.Activity - fields = [ - 'comment', - 'start_datetime', - 'duration', - 'task', - 'user', - 'blocks', - ] - - -class ActivityBlockSerializer(ModelSerializer): - """Activity block serializer.""" - - duration = serializers.DurationField(read_only=True) - - activity = ResourceRelatedField( - queryset=models.Activity.objects.all() - ) - - included_serializers = { - 'activity': 'timed_api.serializers.ActivitySerializer' - } - - class Meta: - """Meta information for the activity block serializer.""" - - model = models.ActivityBlock - fields = [ - 'activity', - 'duration', - 'from_datetime', - 'to_datetime', - ] - - -class AttendanceSerializer(ModelSerializer): - """Attendance serializer.""" - - user = ResourceRelatedField( - read_only=True - ) - - class Meta: - """Meta information for the attendance serializer.""" - - model = models.Attendance - fields = [ - 'from_datetime', - 'to_datetime', - 'user', - ] - - -class ReportSerializer(ModelSerializer): - """Report serializer.""" - - task = ResourceRelatedField( - queryset=models.Task.objects.all(), - allow_null=True, - required=False - ) - - user = ResourceRelatedField( - read_only=True - ) - - included_serializers = { - 'task': 'timed_api.serializers.TaskSerializer', - 'user': 'timed_api.serializers.UserSerializer' - } - - class Meta: - """Meta information for the report serializer.""" - - model = models.Report - fields = [ - 'comment', - 'duration', - 'review', - 'nta', - 'task', - 'user', - ] - - -class CustomerSerializer(ModelSerializer): - """Customer serializer.""" - - projects = ResourceRelatedField( - read_only=True, - many=True - ) - - included_serializers = { - 'projects': 'timed_api.serializers.ProjectSerializer' - } - - class Meta: - """Meta information for the customer serializer.""" - - model = models.Customer - fields = [ - 'name', - 'email', - 'website', - 'comment', - 'archived', - 'projects', - ] - - -class ProjectSerializer(ModelSerializer): - """Project serializer.""" - - customer = ResourceRelatedField( - queryset=models.Customer.objects.all() - ) - - leaders = ResourceRelatedField( - queryset=User.objects.all(), - required=False, - many=True - ) - - tasks = ResourceRelatedField( - read_only=True, - many=True - ) - - included_serializers = { - 'customer': 'timed_api.serializers.CustomerSerializer', - 'leaders': 'timed_api.serializers.UserSerializer', - 'tasks': 'timed_api.serializers.TaskSerializer' - } - - class Meta: - """Meta information for the project serializer.""" - - model = models.Project - fields = [ - 'name', - 'comment', - 'archived', - 'tracker_type', - 'tracker_name', - 'tracker_api_key', - 'customer', - 'leaders', - 'tasks', - ] - - -class TaskSerializer(ModelSerializer): - """Task serializer.""" - - activities = ResourceRelatedField( - read_only=True, - many=True - ) - - project = ResourceRelatedField( - queryset=models.Project.objects.all() - ) - - included_serializers = { - 'activities': 'timed_api.serializers.ActivitySerializer', - 'project': 'timed_api.serializers.ProjectSerializer' - } - - class Meta: - """Meta information for the task serializer.""" - - model = models.Task - fields = [ - 'name', - 'estimated_hours', - 'archived', - 'project', - 'activities', - ] - - -class TaskTemplateSerializer(ModelSerializer): - """Task template serializer.""" - - class Meta: - """Meta information for the task template serializer.""" - - model = models.TaskTemplate - fields = [ - 'name', - ] diff --git a/timed_api/tests/test_task_template.py b/timed_api/tests/test_task_template.py deleted file mode 100644 index e03c22bdc..000000000 --- a/timed_api/tests/test_task_template.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Tests for the task templates endpoint.""" - -from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN) - -from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import TaskTemplateFactory - - -class TaskTemplateTests(JSONAPITestCase): - """Tests for the task templates endpoint. - - This endpoint should be read only for normal users and project admins. - """ - - def setUp(self): - """Setup the environment for the tests.""" - super().setUp() - - self.task_templates = TaskTemplateFactory.create_batch(5) - - def test_task_template_list(self): - """Should respond with a list of task templates.""" - url = reverse('task-template-list') - - noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK - - result = self.result(user_res) - - assert len(result['data']) == len(self.task_templates) - - def test_task_template_detail(self): - """Should respond with a single task template.""" - task_template = self.task_templates[0] - - url = reverse('task-template-detail', args=[ - task_template.id - ]) - - noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK - - def test_task_template_create(self): - """Should create a new task template.""" - data = { - 'data': { - 'type': 'task-templates', - 'id': None, - 'attributes': { - 'name': 'Test Task Template' - } - } - } - - url = reverse('task-template-list') - - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - project_admin_res = self.project_admin_client.post(url, data) - system_admin_res = self.system_admin_client.post(url, data) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_403_FORBIDDEN - assert system_admin_res.status_code == HTTP_201_CREATED - - def test_task_template_update(self): - """Should update an existing task template.""" - task_template = self.task_templates[0] - - data = { - 'data': { - 'type': 'task-templates', - 'id': task_template.id, - 'attributes': { - 'name': 'Test Task Template 2' - } - } - } - - url = reverse('task-template-detail', args=[ - task_template.id - ]) - - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - project_admin_res = self.project_admin_client.patch(url, data) - system_admin_res = self.system_admin_client.patch(url, data) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_403_FORBIDDEN - assert system_admin_res.status_code == HTTP_200_OK - - result = self.result(system_admin_res) - - assert ( - result['data']['attributes']['name'] == - data['data']['attributes']['name'] - ) - - def test_task_template_delete(self): - """Should delete a task template.""" - task_template = self.task_templates[0] - - url = reverse('task-template-detail', args=[ - task_template.id - ]) - - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) - project_admin_res = self.project_admin_client.delete(url) - system_admin_res = self.system_admin_client.delete(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_403_FORBIDDEN - assert system_admin_res.status_code == HTTP_204_NO_CONTENT diff --git a/tracking/__init__.py b/tracking/__init__.py new file mode 100644 index 000000000..bb3ee657a --- /dev/null +++ b/tracking/__init__.py @@ -0,0 +1,3 @@ +# noqa: D104 + +default_app_config = 'tracking.apps.TrackingConfig' diff --git a/timed_api/admin.py b/tracking/admin.py similarity index 69% rename from timed_api/admin.py rename to tracking/admin.py index 77ca7241b..a4500a6d6 100644 --- a/timed_api/admin.py +++ b/tracking/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin -from timed_api import models +from tracking import models class OwnerAdminMixin(object): @@ -54,34 +54,3 @@ class ReportAdmin(OwnerAdminMixin, admin.ModelAdmin): """Report admin view.""" list_display = ['user', 'task', 'duration', 'comment'] - - -@admin.register(models.Customer) -class CustomerAdmin(admin.ModelAdmin): - """Customer admin view.""" - - list_display = ['name'] - search_fields = ['name'] - - -@admin.register(models.Project) -class ProjectAdmin(admin.ModelAdmin): - """Project admin view.""" - - list_display = ['name', 'customer'] - list_filter = ['customer'] - search_fields = ['name', 'customer'] - - -@admin.register(models.Task) -class TaskAdmin(admin.ModelAdmin): - """Task admin view.""" - - list_display = ['__str__'] - - -@admin.register(models.TaskTemplate) -class TaskTemplateAdmin(admin.ModelAdmin): - """Task template admin view.""" - - list_display = ['name'] diff --git a/tracking/apps.py b/tracking/apps.py new file mode 100644 index 000000000..5b7849d28 --- /dev/null +++ b/tracking/apps.py @@ -0,0 +1,9 @@ +"""Configuration for tracking app.""" + +from django.apps import AppConfig + + +class TrackingConfig(AppConfig): + """App configuration for tracking app.""" + + name = 'tracking' diff --git a/timed_api/factories.py b/tracking/factories.py similarity index 60% rename from timed_api/factories.py rename to tracking/factories.py index 3a56d7257..26049b9e8 100644 --- a/timed_api/factories.py +++ b/tracking/factories.py @@ -1,15 +1,14 @@ -"""Factories for testing the Timed API.""" +"""Factories for testing the tracking app.""" import datetime from random import randint -from django.contrib.auth.models import User from factory import Faker, SubFactory, lazy_attribute from factory.django import DjangoModelFactory from faker import Factory as FakerFactory from pytz import timezone -from timed_api import models +from tracking import models tzinfo = timezone('Europe/Zurich') @@ -42,37 +41,11 @@ def end_of_day(day): return begin_of_day(day) + datetime.timedelta(days=1) -class UserFactory(DjangoModelFactory): - """User factory.""" - - first_name = Faker('first_name') - last_name = Faker('last_name') - email = Faker('email') - password = Faker('password', length=12) - - @lazy_attribute - def username(self): - """Generate a username from first and last name. - - :return: The generated username - :rtype: str - """ - return '{0}.{1}'.format( - self.first_name, - self.last_name, - ).lower() - - class Meta: - """Meta informations for the user factory.""" - - model = User - - class AttendanceFactory(DjangoModelFactory): """Attendance factory.""" date = datetime.date.today() - user = SubFactory(UserFactory) + user = SubFactory('employment.factories.UserFactory') @lazy_attribute def from_datetime(self): @@ -105,57 +78,14 @@ class Meta: exclude = ('date',) -class CustomerFactory(DjangoModelFactory): - """Customer factory.""" - - name = Faker('company') - email = Faker('company_email') - website = Faker('url') - comment = Faker('sentence') - archived = False - - class Meta: - """Meta informations for the customer factory.""" - - model = models.Customer - - -class ProjectFactory(DjangoModelFactory): - """Project factory.""" - - name = Faker('catch_phrase') - archived = False - comment = Faker('sentence') - customer = SubFactory(CustomerFactory) - - class Meta: - """Meta informations for the project factory.""" - - model = models.Project - - -class TaskFactory(DjangoModelFactory): - """Task factory.""" - - name = Faker('company_suffix') - estimated_hours = Faker('random_int', min=0, max=2000) - archived = False - project = SubFactory(ProjectFactory) - - class Meta: - """Meta informations for the task factory.""" - - model = models.Task - - class ReportFactory(DjangoModelFactory): """Task factory.""" comment = Faker('sentence') review = False nta = False - task = SubFactory(TaskFactory) - user = SubFactory(UserFactory) + task = SubFactory('projects.factories.TaskFactory') + user = SubFactory('employment.factories.UserFactory') @lazy_attribute def duration(self): @@ -175,23 +105,12 @@ class Meta: model = models.Report -class TaskTemplateFactory(DjangoModelFactory): - """Task template factory.""" - - name = Faker('sentence') - - class Meta: - """Meta informations for the task template factory.""" - - model = models.TaskTemplate - - class ActivityFactory(DjangoModelFactory): """Activity factory.""" comment = Faker('sentence') - task = SubFactory(TaskFactory) - user = SubFactory(UserFactory) + task = SubFactory('projects.factories.TaskFactory') + user = SubFactory('employment.factories.UserFactory') class Meta: """Meta informations for the activity block factory.""" diff --git a/timed_api/filters.py b/tracking/filters.py similarity index 70% rename from timed_api/filters.py rename to tracking/filters.py index 9748135cc..8f7b14ceb 100644 --- a/timed_api/filters.py +++ b/tracking/filters.py @@ -1,12 +1,11 @@ -"""Filters for filtering Timed API endpoint data.""" +"""Filters for filtering the data of the tracking app endpoints.""" import datetime from functools import wraps -from django.contrib.auth.models import User from django_filters import Filter, FilterSet -from timed_api import models +from tracking import models def boolean_filter(func): @@ -72,16 +71,6 @@ def filter(self, qs, value): ).distinct() -class UserFilterSet(FilterSet): - """Filter set for the users endpoint.""" - - class Meta: - """Meta information for the user filter set.""" - - model = User - fields = [] - - class ActivityFilterSet(FilterSet): """Filter set for the activities endpoint.""" @@ -125,43 +114,3 @@ class Meta: model = models.Report fields = ['user'] - - -class CustomerFilterSet(FilterSet): - """Filter set for the customers endpoint.""" - - class Meta: - """Meta information for the customer filter set.""" - - model = models.Customer - fields = ['archived'] - - -class ProjectFilterSet(FilterSet): - """Filter set for the projects endpoint.""" - - class Meta: - """Meta information for the project filter set.""" - - model = models.Project - fields = ['archived', 'customer'] - - -class TaskFilterSet(FilterSet): - """Filter set for the tasks endpoint.""" - - class Meta: - """Meta information for the task filter set.""" - - model = models.Task - fields = ['archived', 'project'] - - -class TaskTemplateFilterSet(FilterSet): - """Filter set for the task templates endpoint.""" - - class Meta: - """Meta information for the task template filter set.""" - - model = models.TaskTemplate - fields = [] diff --git a/tracking/migrations/0001_initial.py b/tracking/migrations/0001_initial.py new file mode 100644 index 000000000..2463c1c49 --- /dev/null +++ b/tracking/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-09 11:27 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Activity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.CharField(blank=True, max_length=255)), + ('start_datetime', models.DateTimeField(auto_now_add=True)), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='projects.Task')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'activities', + }, + ), + migrations.CreateModel( + name='ActivityBlock', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_datetime', models.DateTimeField(auto_now_add=True)), + ('to_datetime', models.DateTimeField(blank=True, null=True)), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='tracking.Activity')), + ], + ), + migrations.CreateModel( + name='Attendance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_datetime', models.DateTimeField()), + ('to_datetime', models.DateTimeField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Report', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.CharField(max_length=255)), + ('duration', models.DurationField()), + ('review', models.BooleanField(default=False)), + ('nta', models.BooleanField(default=False)), + ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='projects.Task')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/tracking/migrations/__init__.py b/tracking/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tracking/models.py b/tracking/models.py new file mode 100644 index 000000000..2bacd68eb --- /dev/null +++ b/tracking/models.py @@ -0,0 +1,134 @@ +"""Models for the tracking app.""" + +from datetime import timedelta + +from django.conf import settings +from django.db import models + + +class Activity(models.Model): + """Activity model. + + An activity represents multiple timeblocks in which a user worked on a + certain task. + """ + + comment = models.CharField(max_length=255, blank=True) + start_datetime = models.DateTimeField(auto_now_add=True) + task = models.ForeignKey('projects.Task', + related_name='activities') + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='activities') + + @property + def duration(self): + """The total duration of this activity. + + :return: The total duration + :rtype: datetime.timedelta + """ + durations = [ + block.duration + for block + in self.blocks.all() + if block.duration + ] + + return sum(durations, timedelta()) + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{0}: {1}'.format(self.user, self.task) + + class Meta: + """Meta informations for the activity model.""" + + verbose_name_plural = 'activities' + + +class ActivityBlock(models.Model): + """Activity block model. + + An activity block is a timeblock of an activity. + """ + + activity = models.ForeignKey('tracking.Activity', + related_name='blocks') + from_datetime = models.DateTimeField(auto_now_add=True) + to_datetime = models.DateTimeField(blank=True, null=True) + + @property + def duration(self): + """The duration of this activity block. + + :return: The duration + :rtype: datetime.timedelta or None + """ + if not self.to_datetime: + return None + + return self.to_datetime - self.from_datetime + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{1} ({0})'.format(self.activity, self.duration) + + +class Attendance(models.Model): + """Attendance model. + + An attendance is a timespan in which a user was present at work. + """ + + from_datetime = models.DateTimeField() + to_datetime = models.DateTimeField() + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='attendances') + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{0}: {1} - {2}'.format( + self.user, + self.from_datetime.strftime('%d.%m.%Y %h:%i'), + self.to_datetime.strftime('%d.%m.%Y %h:%i') + ) + + +class Report(models.Model): + """Report model. + + A report is a timespan in which a user worked on a certain task. + The difference to the activity is, that this is going to be on the + bill for the customer. + """ + + comment = models.CharField(max_length=255) + duration = models.DurationField() + review = models.BooleanField(default=False) + nta = models.BooleanField(default=False) + task = models.ForeignKey('projects.Task', + null=True, + blank=True, + related_name='reports') + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='reports') + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{0}: {1}'.format(self.user, self.task) diff --git a/tracking/serializers.py b/tracking/serializers.py new file mode 100644 index 000000000..eb5dabdb5 --- /dev/null +++ b/tracking/serializers.py @@ -0,0 +1,101 @@ +"""Serializers for the tracking app.""" + +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import DurationField, ModelSerializer + +from projects.models import Task +from tracking import models + + +class ActivitySerializer(ModelSerializer): + """Activity serializer.""" + + duration = DurationField(read_only=True) + task = ResourceRelatedField(queryset=Task.objects.all()) + user = ResourceRelatedField(read_only=True) + blocks = ResourceRelatedField(read_only=True, + many=True,) + + included_serializers = { + 'blocks': 'tracking.serializers.ActivityBlockSerializer', + 'task': 'projects.serializers.TaskSerializer', + 'user': 'employment.serializers.UserSerializer' + } + + class Meta: + """Meta information for the activity serializer.""" + + model = models.Activity + fields = [ + 'comment', + 'start_datetime', + 'duration', + 'task', + 'user', + 'blocks', + ] + + +class ActivityBlockSerializer(ModelSerializer): + """Activity block serializer.""" + + duration = DurationField(read_only=True) + activity = ResourceRelatedField(queryset=models.Activity.objects.all()) + + included_serializers = { + 'activity': 'tracking.serializers.ActivitySerializer' + } + + class Meta: + """Meta information for the activity block serializer.""" + + model = models.ActivityBlock + fields = [ + 'activity', + 'duration', + 'from_datetime', + 'to_datetime', + ] + + +class AttendanceSerializer(ModelSerializer): + """Attendance serializer.""" + + user = ResourceRelatedField(read_only=True) + + class Meta: + """Meta information for the attendance serializer.""" + + model = models.Attendance + fields = [ + 'from_datetime', + 'to_datetime', + 'user', + ] + + +class ReportSerializer(ModelSerializer): + """Report serializer.""" + + task = ResourceRelatedField(queryset=Task.objects.all(), + allow_null=True, + required=False) + user = ResourceRelatedField(read_only=True) + + included_serializers = { + 'task': 'projects.serializers.TaskSerializer', + 'user': 'employment.serializers.UserSerializer' + } + + class Meta: + """Meta information for the report serializer.""" + + model = models.Report + fields = [ + 'comment', + 'duration', + 'review', + 'nta', + 'task', + 'user', + ] diff --git a/tracking/tests/__init__.py b/tracking/tests/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/tracking/tests/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/timed_api/tests/test_activity.py b/tracking/tests/test_activity.py similarity index 98% rename from timed_api/tests/test_activity.py rename to tracking/tests/test_activity.py index ac8b37045..375195aa5 100644 --- a/timed_api/tests/test_activity.py +++ b/tracking/tests/test_activity.py @@ -9,7 +9,7 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import ActivityBlockFactory, ActivityFactory +from tracking.factories import ActivityBlockFactory, ActivityFactory class ActivityTests(JSONAPITestCase): diff --git a/timed_api/tests/test_activity_block.py b/tracking/tests/test_activity_block.py similarity index 98% rename from timed_api/tests/test_activity_block.py rename to tracking/tests/test_activity_block.py index 2e95c28e0..5d21aac80 100644 --- a/timed_api/tests/test_activity_block.py +++ b/tracking/tests/test_activity_block.py @@ -9,7 +9,7 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import ActivityBlockFactory, ActivityFactory +from tracking.factories import ActivityBlockFactory, ActivityFactory class ActivityBlockTests(JSONAPITestCase): diff --git a/timed_api/tests/test_attendance.py b/tracking/tests/test_attendance.py similarity index 98% rename from timed_api/tests/test_attendance.py rename to tracking/tests/test_attendance.py index 8a26bfb24..e4c869ab4 100644 --- a/timed_api/tests/test_attendance.py +++ b/tracking/tests/test_attendance.py @@ -8,7 +8,7 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import AttendanceFactory +from tracking.factories import AttendanceFactory class AttendanceTests(JSONAPITestCase): diff --git a/timed_api/tests/test_report.py b/tracking/tests/test_report.py similarity index 98% rename from timed_api/tests/test_report.py rename to tracking/tests/test_report.py index 0b5bac802..7b68cdb95 100644 --- a/timed_api/tests/test_report.py +++ b/tracking/tests/test_report.py @@ -5,8 +5,9 @@ from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) +from projects.factories import TaskFactory from timed.jsonapi_test_case import JSONAPITestCase -from timed_api.factories import ReportFactory, TaskFactory +from tracking.factories import ReportFactory class ReportTests(JSONAPITestCase): diff --git a/timed_api/urls.py b/tracking/urls.py similarity index 51% rename from timed_api/urls.py rename to tracking/urls.py index 64f665010..e89333eff 100644 --- a/timed_api/urls.py +++ b/tracking/urls.py @@ -1,20 +1,16 @@ -"""URL to view mapping for the Timed API.""" +"""URL to view mapping for the tracking app.""" from django.conf import settings from rest_framework.routers import DefaultRouter -from timed_api import views +from tracking import views r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'users', views.UserViewSet, 'user') +# r.register(r'users', views.UserViewSet, 'user') r.register(r'activities', views.ActivityViewSet, 'activity') r.register(r'activity-blocks', views.ActivityBlockViewSet, 'activity-block') r.register(r'attendances', views.AttendanceViewSet, 'attendance') r.register(r'reports', views.ReportViewSet, 'report') -r.register(r'projects', views.ProjectViewSet, 'project') -r.register(r'customers', views.CustomerViewSet, 'customer') -r.register(r'tasks', views.TaskViewSet, 'task') -r.register(r'task-templates', views.TaskTemplateViewSet, 'task-template') urlpatterns = r.urls diff --git a/timed_api/views.py b/tracking/views.py similarity index 61% rename from timed_api/views.py rename to tracking/views.py index 481a7b728..a152d8bbd 100644 --- a/timed_api/views.py +++ b/tracking/views.py @@ -1,17 +1,8 @@ -"""View sets for the Timed API.""" +"""Viewsets for the tracking app.""" -from django.contrib.auth.models import User -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.viewsets import ModelViewSet -from timed_api import filters, models, serializers - - -class UserViewSet(ReadOnlyModelViewSet): - """User view set.""" - - queryset = User.objects.all() - serializer_class = serializers.UserSerializer - filter_class = filters.UserFilterSet +from tracking import filters, models, serializers class ActivityViewSet(ModelViewSet): @@ -95,39 +86,3 @@ def perform_create(self, serializer): :param ReportSerializer seralizer: The serializer """ serializer.save(user=self.request.user) - - -class CustomerViewSet(ModelViewSet): - """Customer view set.""" - - queryset = models.Customer.objects.filter(archived=False) - serializer_class = serializers.CustomerSerializer - filter_class = filters.CustomerFilterSet - search_fields = ('name',) - ordering = 'name' - - -class ProjectViewSet(ModelViewSet): - """Project view set.""" - - queryset = models.Project.objects.filter(archived=False) - serializer_class = serializers.ProjectSerializer - filter_class = filters.ProjectFilterSet - search_fields = ('name', 'customer__name',) - ordering = ('customer__name', 'name') - - -class TaskViewSet(ModelViewSet): - """Task view set.""" - - queryset = models.Task.objects.all() - serializer_class = serializers.TaskSerializer - filter_class = filters.TaskFilterSet - - -class TaskTemplateViewSet(ModelViewSet): - """Task template view set.""" - - queryset = models.TaskTemplate.objects.all() - serializer_class = serializers.TaskTemplateSerializer - filter_class = filters.TaskTemplateFilterSet From b788743f87a0d143b848a7d723924f6e305e76e8 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 9 Feb 2017 16:51:24 +0100 Subject: [PATCH 046/980] Namespace apps --- employment/__init__.py | 3 --- projects/__init__.py | 3 --- timed/employment/__init__.py | 3 +++ {employment => timed/employment}/apps.py | 3 ++- {employment => timed/employment}/factories.py | 0 {employment => timed/employment}/filters.py | 5 +++-- .../employment}/migrations/__init__.py | 0 {employment => timed/employment}/serializers.py | 6 +++--- .../employment}/tests/__init__.py | 0 .../employment}/tests/test_user.py | 2 +- {employment => timed/employment}/urls.py | 2 +- {employment => timed/employment}/views.py | 6 +++--- timed/projects/__init__.py | 3 +++ {projects => timed/projects}/admin.py | 2 +- {projects => timed/projects}/apps.py | 3 ++- {projects => timed/projects}/factories.py | 6 +++--- {projects => timed/projects}/filters.py | 2 +- .../projects}/migrations/0001_initial.py | 0 .../projects}/migrations/__init__.py | 0 {projects => timed/projects}/models.py | 0 {projects => timed/projects}/serializers.py | 14 +++++++------- {projects => timed/projects}/tests/__init__.py | 0 .../projects}/tests/test_customer.py | 2 +- .../projects}/tests/test_project.py | 4 ++-- {projects => timed/projects}/tests/test_task.py | 2 +- {projects => timed/projects}/urls.py | 2 +- {projects => timed/projects}/views.py | 2 +- timed/settings.py | 7 ++++--- timed/tracking/__init__.py | 3 +++ {tracking => timed/tracking}/admin.py | 2 +- {tracking => timed/tracking}/apps.py | 3 ++- {tracking => timed/tracking}/factories.py | 12 ++++++------ {tracking => timed/tracking}/filters.py | 2 +- .../tracking}/migrations/0001_initial.py | 0 .../tracking}/migrations/__init__.py | 0 {tracking => timed/tracking}/models.py | 0 {tracking => timed/tracking}/serializers.py | 16 ++++++++-------- {tracking => timed/tracking}/tests/__init__.py | 0 .../tracking}/tests/test_activity.py | 2 +- .../tracking}/tests/test_activity_block.py | 2 +- .../tracking}/tests/test_attendance.py | 2 +- .../tracking}/tests/test_report.py | 4 ++-- {tracking => timed/tracking}/urls.py | 3 +-- {tracking => timed/tracking}/views.py | 2 +- timed/urls.py | 6 +++--- tracking/__init__.py | 3 --- 46 files changed, 74 insertions(+), 70 deletions(-) delete mode 100644 employment/__init__.py delete mode 100644 projects/__init__.py create mode 100644 timed/employment/__init__.py rename {employment => timed/employment}/apps.py (74%) rename {employment => timed/employment}/factories.py (100%) rename {employment => timed/employment}/filters.py (77%) rename {employment => timed/employment}/migrations/__init__.py (100%) rename {employment => timed/employment}/serializers.py (87%) rename {employment => timed/employment}/tests/__init__.py (100%) rename {employment => timed/employment}/tests/test_user.py (98%) rename {employment => timed/employment}/urls.py (88%) rename {employment => timed/employment}/views.py (63%) create mode 100644 timed/projects/__init__.py rename {projects => timed/projects}/admin.py (95%) rename {projects => timed/projects}/apps.py (74%) rename {projects => timed/projects}/factories.py (87%) rename {projects => timed/projects}/filters.py (95%) rename {projects => timed/projects}/migrations/0001_initial.py (100%) rename {projects => timed/projects}/migrations/__init__.py (100%) rename {projects => timed/projects}/models.py (100%) rename {projects => timed/projects}/serializers.py (82%) rename {projects => timed/projects}/tests/__init__.py (100%) rename {projects => timed/projects}/tests/test_customer.py (98%) rename {projects => timed/projects}/tests/test_project.py (97%) rename {projects => timed/projects}/tests/test_task.py (99%) rename {projects => timed/projects}/urls.py (92%) rename {projects => timed/projects}/views.py (94%) create mode 100644 timed/tracking/__init__.py rename {tracking => timed/tracking}/admin.py (97%) rename {tracking => timed/tracking}/apps.py (74%) rename {tracking => timed/tracking}/factories.py (90%) rename {tracking => timed/tracking}/filters.py (98%) rename {tracking => timed/tracking}/migrations/0001_initial.py (100%) rename {tracking => timed/tracking}/migrations/__init__.py (100%) rename {tracking => timed/tracking}/models.py (100%) rename {tracking => timed/tracking}/serializers.py (82%) rename {tracking => timed/tracking}/tests/__init__.py (100%) rename {tracking => timed/tracking}/tests/test_activity.py (98%) rename {tracking => timed/tracking}/tests/test_activity_block.py (98%) rename {tracking => timed/tracking}/tests/test_attendance.py (98%) rename {tracking => timed/tracking}/tests/test_report.py (97%) rename {tracking => timed/tracking}/urls.py (83%) rename {tracking => timed/tracking}/views.py (97%) delete mode 100644 tracking/__init__.py diff --git a/employment/__init__.py b/employment/__init__.py deleted file mode 100644 index 92b2df23a..000000000 --- a/employment/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# noqa: D104 - -default_app_config = 'employment.apps.EmploymentConfig' diff --git a/projects/__init__.py b/projects/__init__.py deleted file mode 100644 index 74a996408..000000000 --- a/projects/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# noqa: D104 - -default_app_config = 'projects.apps.ProjectsConfig' diff --git a/timed/employment/__init__.py b/timed/employment/__init__.py new file mode 100644 index 000000000..9a0f828ec --- /dev/null +++ b/timed/employment/__init__.py @@ -0,0 +1,3 @@ +# noqa: D104 + +default_app_config = 'timed.employment.apps.EmploymentConfig' diff --git a/employment/apps.py b/timed/employment/apps.py similarity index 74% rename from employment/apps.py rename to timed/employment/apps.py index 23ac943c1..ee0031ac8 100644 --- a/employment/apps.py +++ b/timed/employment/apps.py @@ -6,4 +6,5 @@ class EmploymentConfig(AppConfig): """App configuration for employment app.""" - name = 'employment' + name = 'timed.employment' + label = 'employment' diff --git a/employment/factories.py b/timed/employment/factories.py similarity index 100% rename from employment/factories.py rename to timed/employment/factories.py diff --git a/employment/filters.py b/timed/employment/filters.py similarity index 77% rename from employment/filters.py rename to timed/employment/filters.py index d3b6bd600..b43db3793 100644 --- a/employment/filters.py +++ b/timed/employment/filters.py @@ -1,6 +1,7 @@ """Filters for filtering the data of the employment app endpoints.""" -from django.contrib.auth.models import User + +from django.contrib.auth import get_user_model from django_filters import FilterSet @@ -10,5 +11,5 @@ class UserFilterSet(FilterSet): class Meta: """Meta information for the user filter set.""" - model = User + model = get_user_model() fields = [] diff --git a/employment/migrations/__init__.py b/timed/employment/migrations/__init__.py similarity index 100% rename from employment/migrations/__init__.py rename to timed/employment/migrations/__init__.py diff --git a/employment/serializers.py b/timed/employment/serializers.py similarity index 87% rename from employment/serializers.py rename to timed/employment/serializers.py index 9d584c5e0..dd05ffe81 100644 --- a/employment/serializers.py +++ b/timed/employment/serializers.py @@ -1,6 +1,6 @@ """Serializers for the employment app.""" -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ModelSerializer @@ -18,13 +18,13 @@ class UserSerializer(ModelSerializer): many=True) included_serializers = { - 'projects': 'projects.serializers.ProjectSerializer' + 'projects': 'timed.projects.serializers.ProjectSerializer' } class Meta: """Meta information for the user serializer.""" - model = User + model = get_user_model() fields = [ 'username', 'first_name', diff --git a/employment/tests/__init__.py b/timed/employment/tests/__init__.py similarity index 100% rename from employment/tests/__init__.py rename to timed/employment/tests/__init__.py diff --git a/employment/tests/test_user.py b/timed/employment/tests/test_user.py similarity index 98% rename from employment/tests/test_user.py rename to timed/employment/tests/test_user.py index 5d45b6d3e..ac5318bc4 100644 --- a/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -4,7 +4,7 @@ from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) -from employment.factories import UserFactory +from timed.employment.factories import UserFactory from timed.jsonapi_test_case import JSONAPITestCase diff --git a/employment/urls.py b/timed/employment/urls.py similarity index 88% rename from employment/urls.py rename to timed/employment/urls.py index cd3f3eb8b..4db3efed2 100644 --- a/employment/urls.py +++ b/timed/employment/urls.py @@ -3,7 +3,7 @@ from django.conf import settings from rest_framework.routers import DefaultRouter -from employment import views +from timed.employment import views r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) diff --git a/employment/views.py b/timed/employment/views.py similarity index 63% rename from employment/views.py rename to timed/employment/views.py index 487a86b34..60de91a24 100644 --- a/employment/views.py +++ b/timed/employment/views.py @@ -1,14 +1,14 @@ """Viewsets for the employment app.""" -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from rest_framework.viewsets import ReadOnlyModelViewSet -from employment import filters, serializers +from timed.employment import filters, serializers class UserViewSet(ReadOnlyModelViewSet): """User view set.""" - queryset = User.objects.all() + queryset = get_user_model().objects.all() serializer_class = serializers.UserSerializer filter_class = filters.UserFilterSet diff --git a/timed/projects/__init__.py b/timed/projects/__init__.py new file mode 100644 index 000000000..6df94d2af --- /dev/null +++ b/timed/projects/__init__.py @@ -0,0 +1,3 @@ +# noqa: D104 + +default_app_config = 'timed.projects.apps.ProjectsConfig' diff --git a/projects/admin.py b/timed/projects/admin.py similarity index 95% rename from projects/admin.py rename to timed/projects/admin.py index 067776dd3..f15fcec38 100644 --- a/projects/admin.py +++ b/timed/projects/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin -from projects import models +from timed.projects import models @admin.register(models.Customer) diff --git a/projects/apps.py b/timed/projects/apps.py similarity index 74% rename from projects/apps.py rename to timed/projects/apps.py index 6eff0fcdc..afa2f4307 100644 --- a/projects/apps.py +++ b/timed/projects/apps.py @@ -6,4 +6,5 @@ class ProjectsConfig(AppConfig): """App configuration for projects app.""" - name = 'projects' + name = 'timed.projects' + label = 'projects' diff --git a/projects/factories.py b/timed/projects/factories.py similarity index 87% rename from projects/factories.py rename to timed/projects/factories.py index d522ba066..96f0a904b 100644 --- a/projects/factories.py +++ b/timed/projects/factories.py @@ -3,7 +3,7 @@ from factory import Faker, SubFactory from factory.django import DjangoModelFactory -from projects import models +from timed.projects import models class CustomerFactory(DjangoModelFactory): @@ -27,7 +27,7 @@ class ProjectFactory(DjangoModelFactory): name = Faker('catch_phrase') archived = False comment = Faker('sentence') - customer = SubFactory('projects.factories.CustomerFactory') + customer = SubFactory('timed.projects.factories.CustomerFactory') class Meta: """Meta informations for the project factory.""" @@ -41,7 +41,7 @@ class TaskFactory(DjangoModelFactory): name = Faker('company_suffix') estimated_hours = Faker('random_int', min=0, max=2000) archived = False - project = SubFactory('projects.factories.ProjectFactory') + project = SubFactory('timed.projects.factories.ProjectFactory') class Meta: """Meta informations for the task factory.""" diff --git a/projects/filters.py b/timed/projects/filters.py similarity index 95% rename from projects/filters.py rename to timed/projects/filters.py index f2023c103..9a571c364 100644 --- a/projects/filters.py +++ b/timed/projects/filters.py @@ -2,7 +2,7 @@ from django_filters import FilterSet -from projects import models +from timed.projects import models class CustomerFilterSet(FilterSet): diff --git a/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py similarity index 100% rename from projects/migrations/0001_initial.py rename to timed/projects/migrations/0001_initial.py diff --git a/projects/migrations/__init__.py b/timed/projects/migrations/__init__.py similarity index 100% rename from projects/migrations/__init__.py rename to timed/projects/migrations/__init__.py diff --git a/projects/models.py b/timed/projects/models.py similarity index 100% rename from projects/models.py rename to timed/projects/models.py diff --git a/projects/serializers.py b/timed/projects/serializers.py similarity index 82% rename from projects/serializers.py rename to timed/projects/serializers.py index 2cf5ef769..4e60bf9af 100644 --- a/projects/serializers.py +++ b/timed/projects/serializers.py @@ -4,7 +4,7 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ModelSerializer -from projects import models +from timed.projects import models class CustomerSerializer(ModelSerializer): @@ -14,7 +14,7 @@ class CustomerSerializer(ModelSerializer): many=True) included_serializers = { - 'projects': 'projects.serializers.ProjectSerializer' + 'projects': 'timed.projects.serializers.ProjectSerializer' } class Meta: @@ -42,9 +42,9 @@ class ProjectSerializer(ModelSerializer): many=True) included_serializers = { - 'customer': 'projects.serializers.CustomerSerializer', - 'leaders': 'employment.serializers.UserSerializer', - 'tasks': 'projects.serializers.TaskSerializer' + 'customer': 'timed.projects.serializers.CustomerSerializer', + 'leaders': 'timed.employment.serializers.UserSerializer', + 'tasks': 'timed.projects.serializers.TaskSerializer' } class Meta: @@ -72,8 +72,8 @@ class TaskSerializer(ModelSerializer): project = ResourceRelatedField(queryset=models.Project.objects.all()) included_serializers = { - 'activities': 'tracking.serializers.ActivitySerializer', - 'project': 'projects.serializers.ProjectSerializer' + 'activities': 'timed.tracking.serializers.ActivitySerializer', + 'project': 'timed.projects.serializers.ProjectSerializer' } class Meta: diff --git a/projects/tests/__init__.py b/timed/projects/tests/__init__.py similarity index 100% rename from projects/tests/__init__.py rename to timed/projects/tests/__init__.py diff --git a/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py similarity index 98% rename from projects/tests/test_customer.py rename to timed/projects/tests/test_customer.py index a84365663..9f97388f4 100644 --- a/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -5,8 +5,8 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) -from projects.factories import CustomerFactory from timed.jsonapi_test_case import JSONAPITestCase +from timed.projects.factories import CustomerFactory class CustomerTests(JSONAPITestCase): diff --git a/projects/tests/test_project.py b/timed/projects/tests/test_project.py similarity index 97% rename from projects/tests/test_project.py rename to timed/projects/tests/test_project.py index 49b621565..f15497bd1 100644 --- a/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -5,9 +5,9 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) -from projects.factories import ProjectFactory, TaskTemplateFactory -from projects.models import Task from timed.jsonapi_test_case import JSONAPITestCase +from timed.projects.factories import ProjectFactory, TaskTemplateFactory +from timed.projects.models import Task class ProjectTests(JSONAPITestCase): diff --git a/projects/tests/test_task.py b/timed/projects/tests/test_task.py similarity index 99% rename from projects/tests/test_task.py rename to timed/projects/tests/test_task.py index 7a6aa2abe..a28fd420d 100644 --- a/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -5,8 +5,8 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) -from projects.factories import TaskFactory from timed.jsonapi_test_case import JSONAPITestCase +from timed.projects.factories import TaskFactory class TaskTests(JSONAPITestCase): diff --git a/projects/urls.py b/timed/projects/urls.py similarity index 92% rename from projects/urls.py rename to timed/projects/urls.py index 37b94a257..ae74d0a70 100644 --- a/projects/urls.py +++ b/timed/projects/urls.py @@ -3,7 +3,7 @@ from django.conf import settings from rest_framework.routers import DefaultRouter -from projects import views +from timed.projects import views r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) diff --git a/projects/views.py b/timed/projects/views.py similarity index 94% rename from projects/views.py rename to timed/projects/views.py index 7a51a91bc..554ec74d2 100644 --- a/projects/views.py +++ b/timed/projects/views.py @@ -2,7 +2,7 @@ from rest_framework.viewsets import ModelViewSet -from projects import filters, models, serializers +from timed.projects import filters, models, serializers class CustomerViewSet(ModelViewSet): diff --git a/timed/settings.py b/timed/settings.py index e704bbf40..83c44f0f6 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -39,7 +39,7 @@ def trueish(value): # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['*'] # Application definition @@ -54,8 +54,9 @@ def trueish(value): 'django.contrib.staticfiles', 'rest_framework', 'crispy_forms', - 'projects', - 'tracking', + 'timed.employment', + 'timed.projects', + 'timed.tracking', ] MIDDLEWARE_CLASSES = [ diff --git a/timed/tracking/__init__.py b/timed/tracking/__init__.py new file mode 100644 index 000000000..e79016426 --- /dev/null +++ b/timed/tracking/__init__.py @@ -0,0 +1,3 @@ +# noqa: D104 + +default_app_config = 'timed.tracking.apps.TrackingConfig' diff --git a/tracking/admin.py b/timed/tracking/admin.py similarity index 97% rename from tracking/admin.py rename to timed/tracking/admin.py index a4500a6d6..d28bc2487 100644 --- a/tracking/admin.py +++ b/timed/tracking/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin -from tracking import models +from timed.tracking import models class OwnerAdminMixin(object): diff --git a/tracking/apps.py b/timed/tracking/apps.py similarity index 74% rename from tracking/apps.py rename to timed/tracking/apps.py index 5b7849d28..2c1991238 100644 --- a/tracking/apps.py +++ b/timed/tracking/apps.py @@ -6,4 +6,5 @@ class TrackingConfig(AppConfig): """App configuration for tracking app.""" - name = 'tracking' + name = 'timed.tracking' + label = 'tracking' diff --git a/tracking/factories.py b/timed/tracking/factories.py similarity index 90% rename from tracking/factories.py rename to timed/tracking/factories.py index 26049b9e8..6a4b85245 100644 --- a/tracking/factories.py +++ b/timed/tracking/factories.py @@ -8,7 +8,7 @@ from faker import Factory as FakerFactory from pytz import timezone -from tracking import models +from timed.tracking import models tzinfo = timezone('Europe/Zurich') @@ -45,7 +45,7 @@ class AttendanceFactory(DjangoModelFactory): """Attendance factory.""" date = datetime.date.today() - user = SubFactory('employment.factories.UserFactory') + user = SubFactory('timed.employment.factories.UserFactory') @lazy_attribute def from_datetime(self): @@ -84,8 +84,8 @@ class ReportFactory(DjangoModelFactory): comment = Faker('sentence') review = False nta = False - task = SubFactory('projects.factories.TaskFactory') - user = SubFactory('employment.factories.UserFactory') + task = SubFactory('timed.projects.factories.TaskFactory') + user = SubFactory('timed.employment.factories.UserFactory') @lazy_attribute def duration(self): @@ -109,8 +109,8 @@ class ActivityFactory(DjangoModelFactory): """Activity factory.""" comment = Faker('sentence') - task = SubFactory('projects.factories.TaskFactory') - user = SubFactory('employment.factories.UserFactory') + task = SubFactory('timed.projects.factories.TaskFactory') + user = SubFactory('timed.employment.factories.UserFactory') class Meta: """Meta informations for the activity block factory.""" diff --git a/tracking/filters.py b/timed/tracking/filters.py similarity index 98% rename from tracking/filters.py rename to timed/tracking/filters.py index 8f7b14ceb..fed74f65f 100644 --- a/tracking/filters.py +++ b/timed/tracking/filters.py @@ -5,7 +5,7 @@ from django_filters import Filter, FilterSet -from tracking import models +from timed.tracking import models def boolean_filter(func): diff --git a/tracking/migrations/0001_initial.py b/timed/tracking/migrations/0001_initial.py similarity index 100% rename from tracking/migrations/0001_initial.py rename to timed/tracking/migrations/0001_initial.py diff --git a/tracking/migrations/__init__.py b/timed/tracking/migrations/__init__.py similarity index 100% rename from tracking/migrations/__init__.py rename to timed/tracking/migrations/__init__.py diff --git a/tracking/models.py b/timed/tracking/models.py similarity index 100% rename from tracking/models.py rename to timed/tracking/models.py diff --git a/tracking/serializers.py b/timed/tracking/serializers.py similarity index 82% rename from tracking/serializers.py rename to timed/tracking/serializers.py index eb5dabdb5..7b4133c65 100644 --- a/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -3,8 +3,8 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import DurationField, ModelSerializer -from projects.models import Task -from tracking import models +from timed.projects.models import Task +from timed.tracking import models class ActivitySerializer(ModelSerializer): @@ -17,9 +17,9 @@ class ActivitySerializer(ModelSerializer): many=True,) included_serializers = { - 'blocks': 'tracking.serializers.ActivityBlockSerializer', - 'task': 'projects.serializers.TaskSerializer', - 'user': 'employment.serializers.UserSerializer' + 'blocks': 'timed.tracking.serializers.ActivityBlockSerializer', + 'task': 'timed.projects.serializers.TaskSerializer', + 'user': 'timed.employment.serializers.UserSerializer' } class Meta: @@ -43,7 +43,7 @@ class ActivityBlockSerializer(ModelSerializer): activity = ResourceRelatedField(queryset=models.Activity.objects.all()) included_serializers = { - 'activity': 'tracking.serializers.ActivitySerializer' + 'timed.activity': 'timed.tracking.serializers.ActivitySerializer' } class Meta: @@ -83,8 +83,8 @@ class ReportSerializer(ModelSerializer): user = ResourceRelatedField(read_only=True) included_serializers = { - 'task': 'projects.serializers.TaskSerializer', - 'user': 'employment.serializers.UserSerializer' + 'task': 'timed.projects.serializers.TaskSerializer', + 'user': 'timed.employment.serializers.UserSerializer' } class Meta: diff --git a/tracking/tests/__init__.py b/timed/tracking/tests/__init__.py similarity index 100% rename from tracking/tests/__init__.py rename to timed/tracking/tests/__init__.py diff --git a/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py similarity index 98% rename from tracking/tests/test_activity.py rename to timed/tracking/tests/test_activity.py index 375195aa5..3e821c267 100644 --- a/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -9,7 +9,7 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) from timed.jsonapi_test_case import JSONAPITestCase -from tracking.factories import ActivityBlockFactory, ActivityFactory +from timed.tracking.factories import ActivityBlockFactory, ActivityFactory class ActivityTests(JSONAPITestCase): diff --git a/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py similarity index 98% rename from tracking/tests/test_activity_block.py rename to timed/tracking/tests/test_activity_block.py index 5d21aac80..5847fd542 100644 --- a/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -9,7 +9,7 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) from timed.jsonapi_test_case import JSONAPITestCase -from tracking.factories import ActivityBlockFactory, ActivityFactory +from timed.tracking.factories import ActivityBlockFactory, ActivityFactory class ActivityBlockTests(JSONAPITestCase): diff --git a/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py similarity index 98% rename from tracking/tests/test_attendance.py rename to timed/tracking/tests/test_attendance.py index e4c869ab4..2a461f01f 100644 --- a/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -8,7 +8,7 @@ HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) from timed.jsonapi_test_case import JSONAPITestCase -from tracking.factories import AttendanceFactory +from timed.tracking.factories import AttendanceFactory class AttendanceTests(JSONAPITestCase): diff --git a/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py similarity index 97% rename from tracking/tests/test_report.py rename to timed/tracking/tests/test_report.py index 7b68cdb95..7c0db817b 100644 --- a/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -5,9 +5,9 @@ from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) -from projects.factories import TaskFactory from timed.jsonapi_test_case import JSONAPITestCase -from tracking.factories import ReportFactory +from timed.projects.factories import TaskFactory +from timed.tracking.factories import ReportFactory class ReportTests(JSONAPITestCase): diff --git a/tracking/urls.py b/timed/tracking/urls.py similarity index 83% rename from tracking/urls.py rename to timed/tracking/urls.py index e89333eff..7f160fe4d 100644 --- a/tracking/urls.py +++ b/timed/tracking/urls.py @@ -3,11 +3,10 @@ from django.conf import settings from rest_framework.routers import DefaultRouter -from tracking import views +from timed.tracking import views r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -# r.register(r'users', views.UserViewSet, 'user') r.register(r'activities', views.ActivityViewSet, 'activity') r.register(r'activity-blocks', views.ActivityBlockViewSet, 'activity-block') r.register(r'attendances', views.AttendanceViewSet, 'attendance') diff --git a/tracking/views.py b/timed/tracking/views.py similarity index 97% rename from tracking/views.py rename to timed/tracking/views.py index a152d8bbd..6fd05403e 100644 --- a/tracking/views.py +++ b/timed/tracking/views.py @@ -2,7 +2,7 @@ from rest_framework.viewsets import ModelViewSet -from tracking import filters, models, serializers +from timed.tracking import filters, models, serializers class ActivityViewSet(ModelViewSet): diff --git a/timed/urls.py b/timed/urls.py index 9956faed0..63ad7188f 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -9,7 +9,7 @@ url(r'^admin/', admin.site.urls), url(r'^api/v1/auth/login', obtain_jwt_token, name='login'), url(r'^api/v1/auth/refresh', refresh_jwt_token, name='refresh'), - url(r'^api/v1/', include('employment.urls')), - url(r'^api/v1/', include('projects.urls')), - url(r'^api/v1/', include('tracking.urls')) + url(r'^api/v1/', include('timed.employment.urls')), + url(r'^api/v1/', include('timed.projects.urls')), + url(r'^api/v1/', include('timed.tracking.urls')) ] diff --git a/tracking/__init__.py b/tracking/__init__.py deleted file mode 100644 index bb3ee657a..000000000 --- a/tracking/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# noqa: D104 - -default_app_config = 'tracking.apps.TrackingConfig' From 2639a683a928b1b4fca29e623cd5f93daaab630c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 16 Feb 2017 12:59:31 +0100 Subject: [PATCH 047/980] Fixed make targets --- Makefile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 2bfdd0ef9..e4d39f546 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ .PHONY: help install install-dev setup-ldap create-ldap-user start docs test .DEFAULT_GOAL := help +UCS_CONTAINER_ID=$(shell docker-compose ps -q ucs) + help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -14,11 +16,11 @@ install-dev: ## Install development environment @pip install -e . setup-ldap: ## Setup the LDAP container - docker exec -it timedbackendsrc_ucs_1 /usr/lib/univention-system-setup/scripts/setup-join.sh - docker exec -it timedbackendsrc_ucs_1 /usr/ucs/scripts/init.sh + docker exec -it $(UCS_CONTAINER_ID) /usr/lib/univention-system-setup/scripts/setup-join.sh + docker exec -it $(UCS_CONTAINER_ID) /usr/ucs/scripts/init.sh create-ldap-user: ## Create a new user in the LDAP - docker exec -it timedbackendsrc_ucs_1 /usr/ucs/scripts/create-new-user.sh + docker exec -it $(UCS_CONTAINER_ID) /usr/ucs/scripts/create-new-user.sh start: ## Start the development server @docker-compose start From 900c77f661da435003f0fcb9872801dfe1e9359b Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Feb 2017 16:17:07 +0100 Subject: [PATCH 048/980] * Added locations * Added public holidays * Added employments * Simplified API * Changed to readonly endpoints for most of the objects * Added some admin views --- timed/config.sample.ini | 16 --- timed/employment/admin.py | 29 +++++ timed/employment/factories.py | 54 ++++++++- timed/employment/filters.py | 33 +++-- timed/employment/migrations/0001_initial.py | 56 +++++++++ timed/employment/models.py | 73 +++++++++++ timed/employment/serializers.py | 73 +++++++++-- timed/employment/tests/test_employment.py | 89 ++++++++++++++ timed/employment/tests/test_location.py | 87 +++++++++++++ timed/employment/tests/test_public_holiday.py | 105 ++++++++++++++++ timed/employment/tests/test_user.py | 93 +++++++------- timed/employment/urls.py | 5 +- timed/employment/views.py | 44 ++++++- timed/jsonapi_test_case.py | 108 ++--------------- timed/projects/factories.py | 9 +- timed/projects/migrations/0001_initial.py | 9 +- timed/projects/models.py | 8 +- timed/projects/serializers.py | 10 +- timed/projects/tests/test_customer.py | 75 +++--------- timed/projects/tests/test_project.py | 103 +++------------- timed/projects/tests/test_task.py | 114 ++++-------------- timed/projects/views.py | 10 +- timed/settings.py | 38 ++---- timed/tracking/factories.py | 1 + timed/tracking/filters.py | 4 +- timed/tracking/migrations/0001_initial.py | 3 +- timed/tracking/models.py | 1 + timed/tracking/serializers.py | 6 +- timed/tracking/tests/test_activity.py | 26 ++-- timed/tracking/tests/test_activity_block.py | 26 ++-- timed/tracking/tests/test_attendance.py | 26 ++-- timed/tracking/tests/test_report.py | 11 +- 32 files changed, 815 insertions(+), 530 deletions(-) delete mode 100644 timed/config.sample.ini create mode 100644 timed/employment/admin.py create mode 100644 timed/employment/migrations/0001_initial.py create mode 100644 timed/employment/models.py create mode 100644 timed/employment/tests/test_employment.py create mode 100644 timed/employment/tests/test_location.py create mode 100644 timed/employment/tests/test_public_holiday.py diff --git a/timed/config.sample.ini b/timed/config.sample.ini deleted file mode 100644 index 345767103..000000000 --- a/timed/config.sample.ini +++ /dev/null @@ -1,16 +0,0 @@ -[ldap] -AUTH_LDAP_SERVER_URI = ldap://localhost:389 -AUTH_LDAP_BIND_DN = uid=Administrator,cn=users,dc=example,dc=com -AUTH_LDAP_PASSWORD = univention -AUTH_LDAP_USER_DN_TEMPLATE = uid=%%(user)s,cn=users,dc=example,dc=com - -[github] -GITHUB_API_URL = https://api.github.com/repos/{}/issues -GITHUB_ISSUE_URL = https://github.com/{}/issues/{} - -[redmine] -REDMINE_API_URL = https://redmine.example.com/projects/{}/issues.json -REDMINE_ISSUE_URL = https://redmine.example.com/issues/{} -REDMINE_BASIC_AUTH = true -REDMINE_BASIC_AUTH_USER = admin -REDMINE_BASIC_AUTH_PASSWORD = ********** diff --git a/timed/employment/admin.py b/timed/employment/admin.py new file mode 100644 index 000000000..cacc34aac --- /dev/null +++ b/timed/employment/admin.py @@ -0,0 +1,29 @@ +"""Views for the admin interface.""" + +from django.contrib import admin + +from timed.employment import models + + +@admin.register(models.Location) +class LocationAdmin(admin.ModelAdmin): + """Location admin view.""" + + list_display = ['name'] + search_fields = ['name'] + + +@admin.register(models.Employment) +class EmploymentAdmin(admin.ModelAdmin): + """Employment admin view.""" + + list_display = ['__str__', 'percentage', 'location'] + list_filter = ['location', 'user'] + + +@admin.register(models.PublicHoliday) +class PublicHolidayAdmin(admin.ModelAdmin): + """Public holiday admin view.""" + + list_display = ['__str__', 'date', 'location'] + list_filter = ['location'] diff --git a/timed/employment/factories.py b/timed/employment/factories.py index d9146679a..31c0fe349 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -1,9 +1,13 @@ """Factories for testing the tracking app.""" +import datetime + from django.conf import settings -from factory import Faker, lazy_attribute +from factory import Faker, SubFactory, lazy_attribute from factory.django import DjangoModelFactory +from timed.employment import models + class UserFactory(DjangoModelFactory): """User factory.""" @@ -29,3 +33,51 @@ class Meta: """Meta informations for the user factory.""" model = settings.AUTH_USER_MODEL + + +class LocationFactory(DjangoModelFactory): + """Location factory.""" + + name = Faker('city') + + class Meta: + """Meta informations for the location factory.""" + + model = models.Location + + +class PublicHolidayFactory(DjangoModelFactory): + """Public holiday factory.""" + + name = Faker('word') + date = Faker('date_object') + location = SubFactory(LocationFactory) + + class Meta: + """Meta informations for the public holiday factory.""" + + model = models.PublicHoliday + + +class EmploymentFactory(DjangoModelFactory): + """Employment factory.""" + + user = SubFactory(UserFactory) + location = SubFactory(LocationFactory) + percentage = Faker('random_int', min=50, max=100) + start_date = Faker('date_object') + end_date = None + + @lazy_attribute + def worktime_per_day(self): + """Generate the worktime per day based on the percentage. + + :return The generated worktime + :rtype datetime.timedelta + """ + return datetime.timedelta(minutes=60 * 8.5 * self.percentage) + + class Meta: + """Meta informations for the employment factory.""" + + model = models.Employment diff --git a/timed/employment/filters.py b/timed/employment/filters.py index b43db3793..2d9476f52 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,15 +1,34 @@ """Filters for filtering the data of the employment app endpoints.""" -from django.contrib.auth import get_user_model -from django_filters import FilterSet +from django_filters import Filter, FilterSet +from timed.employment import models -class UserFilterSet(FilterSet): - """Filter set for the users endpoint.""" + +class YearFilter(Filter): + """Filter to filter a queryset by year.""" + + def filter(self, qs, value): + """Filter the queryset. + + :param QuerySet qs: The queryset to filter + :param str value: The year to filter to + :return: The filtered queryset + :rtype: QuerySet + """ + return qs.filter(**{ + '%s__year' % self.name: value + }) + + +class PublicHolidayFilterSet(FilterSet): + """Filter set for the public holidays endpoint.""" + + year = YearFilter(name='date') class Meta: - """Meta information for the user filter set.""" + """Meta information for the public holiday filter set.""" - model = get_user_model() - fields = [] + model = models.PublicHoliday + fields = ['year', 'location', 'date'] diff --git a/timed/employment/migrations/0001_initial.py b/timed/employment/migrations/0001_initial.py new file mode 100644 index 000000000..e75afc085 --- /dev/null +++ b/timed/employment/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-20 11:55 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Employment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('percentage', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('worktime_per_day', models.DurationField()), + ('start_date', models.DateField()), + ('end_date', models.DateField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Location', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ], + ), + migrations.CreateModel( + name='PublicHoliday', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('date', models.DateField()), + ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='public_holidays', to='employment.Location')), + ], + ), + migrations.AddField( + model_name='employment', + name='location', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.Location'), + ), + migrations.AddField( + model_name='employment', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employments', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py new file mode 100644 index 000000000..5c71ba055 --- /dev/null +++ b/timed/employment/models.py @@ -0,0 +1,73 @@ +"""Models for the employment app.""" + +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + + +class Location(models.Model): + """Location model. + + A location is the place where an employee works. + """ + + name = models.CharField(max_length=50) + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return self.name + + +class Employment(models.Model): + """Employment model. + + An employment represents a contract which defines where an employee works + and from when to when. + """ + + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='employments') + location = models.ForeignKey(Location) + percentage = models.IntegerField(validators=[ + MinValueValidator(0), + MaxValueValidator(100)]) + worktime_per_day = models.DurationField() + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{0} ({1} - {2})'.format( + self.user.username, + self.start_date.strftime('%d.%m.%Y'), + self.end_date.strftime('%d.%m.%Y') if self.end_date else 'today' + ) + + +class PublicHoliday(models.Model): + """Public holiday model. + + A public holiday is a day on which no employee of a certain location has + to work. + """ + + name = models.CharField(max_length=50) + date = models.DateField() + location = models.ForeignKey(Location, + related_name='public_holidays') + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return '{0} {1}'.format(self.name, self.date.strftime('%Y')) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index dd05ffe81..76803d016 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -4,21 +4,16 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ModelSerializer +from timed.employment import models + class UserSerializer(ModelSerializer): """User serializer.""" - projects = ResourceRelatedField(read_only=True, - many=True) - attendances = ResourceRelatedField(read_only=True, - many=True) - activities = ResourceRelatedField(read_only=True, - many=True) - reports = ResourceRelatedField(read_only=True, - many=True) + employments = ResourceRelatedField(many=True, read_only=True) included_serializers = { - 'projects': 'timed.projects.serializers.ProjectSerializer' + 'employments': 'timed.employment.serializers.EmploymentSerializer' } class Meta: @@ -30,8 +25,60 @@ class Meta: 'first_name', 'last_name', 'email', - 'projects', - 'attendances', - 'activities', - 'reports', + 'employments', + ] + + +class EmploymentSerializer(ModelSerializer): + """Employment serializer.""" + + user = ResourceRelatedField(read_only=True) + location = ResourceRelatedField(read_only=True) + + included_serializers = { + 'user': 'timed.employment.serializers.UserSerializer', + 'location': 'timed.employment.serializers.LocationSerializer' + } + + class Meta: + """Meta information for the employment serializer.""" + + model = models.Employment + fields = [ + 'user', + 'location', + 'percentage', + 'worktime_per_day', + 'start_date', + 'end_date', + ] + + +class LocationSerializer(ModelSerializer): + """Location serializer.""" + + class Meta: + """Meta information for the location serializer.""" + + model = models.Location + fields = ['name'] + + +class PublicHolidaySerializer(ModelSerializer): + """Public holiday serializer.""" + + location = ResourceRelatedField(read_only=True) + + included_serializers = { + 'location': 'timed.employment.serializers.LocationSerializer' + } + + class Meta: + """Meta information for the public holiday serializer.""" + + model = models.PublicHoliday + fields = [ + 'name', + 'date', + 'location', ] diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py new file mode 100644 index 000000000..b78802448 --- /dev/null +++ b/timed/employment/tests/test_employment.py @@ -0,0 +1,89 @@ +"""Tests for the employments endpoint.""" + +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) + +from timed.employment.factories import EmploymentFactory +from timed.jsonapi_test_case import JSONAPITestCase + + +class EmploymentTests(JSONAPITestCase): + """Tests for the employment endpoint. + + This endpoint should be read only for normal users. + """ + + def setUp(self): + """Setup the environment for the tests.""" + super().setUp() + + self.employments = EmploymentFactory.create_batch(2, user=self.user) + + EmploymentFactory.create_batch(10) + + def test_employment_list(self): + """Should respond with a list of employments.""" + url = reverse('employment-list') + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == len(self.employments) + + def test_employment_detail(self): + """Should respond with a single employment.""" + employment = self.employments[0] + + url = reverse('employment-detail', args=[ + employment.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_employment_create(self): + """Should not be able to create a new employment.""" + url = reverse('employment-list') + + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_employment_update(self): + """Should not be able to update an existing employment.""" + employment = self.employments[0] + + url = reverse('employment-detail', args=[ + employment.id + ]) + + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_employment_delete(self): + """Should not be able delete a employment.""" + employment = self.employments[0] + + url = reverse('employment-detail', args=[ + employment.id + ]) + + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_location.py b/timed/employment/tests/test_location.py new file mode 100644 index 000000000..aa7e27b0e --- /dev/null +++ b/timed/employment/tests/test_location.py @@ -0,0 +1,87 @@ +"""Tests for the locations endpoint.""" + +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) + +from timed.employment.factories import LocationFactory +from timed.jsonapi_test_case import JSONAPITestCase + + +class LocationTests(JSONAPITestCase): + """Tests for the location endpoint. + + This endpoint should be read only for normal users. + """ + + def setUp(self): + """Setup the environment for the tests.""" + super().setUp() + + self.locations = LocationFactory.create_batch(3) + + def test_location_list(self): + """Should respond with a list of locations.""" + url = reverse('location-list') + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == len(self.locations) + + def test_location_detail(self): + """Should respond with a single location.""" + location = self.locations[0] + + url = reverse('location-detail', args=[ + location.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_location_create(self): + """Should not be able to create a new location.""" + url = reverse('location-list') + + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_location_update(self): + """Should not be able to update an existing location.""" + location = self.locations[0] + + url = reverse('location-detail', args=[ + location.id + ]) + + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_location_delete(self): + """Should not be able delete a location.""" + location = self.locations[0] + + url = reverse('location-detail', args=[ + location.id + ]) + + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_public_holiday.py b/timed/employment/tests/test_public_holiday.py new file mode 100644 index 000000000..b0d20a247 --- /dev/null +++ b/timed/employment/tests/test_public_holiday.py @@ -0,0 +1,105 @@ +"""Tests for the public holidays endpoint.""" + +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) + +from timed.employment.factories import PublicHolidayFactory +from timed.employment.models import PublicHoliday +from timed.jsonapi_test_case import JSONAPITestCase + + +class PublicHolidayTests(JSONAPITestCase): + """Tests for the public holiday endpoint. + + This endpoint should be read only for normal users. + """ + + def setUp(self): + """Setup the environment for the tests.""" + super().setUp() + + self.public_holidays = PublicHolidayFactory.create_batch(10) + + def test_public_holiday_list(self): + """Should respond with a list of public holidays.""" + url = reverse('public-holiday-list') + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == len(self.public_holidays) + + def test_public_holiday_detail(self): + """Should respond with a single public holiday.""" + public_holiday = self.public_holidays[0] + + url = reverse('public-holiday-detail', args=[ + public_holiday.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_public_holiday_create(self): + """Should not be able to create a new public holiday.""" + url = reverse('public-holiday-list') + + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_public_holiday_update(self): + """Should not be able to update an existing public holiday.""" + public_holiday = self.public_holidays[0] + + url = reverse('public-holiday-detail', args=[ + public_holiday.id + ]) + + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_public_holiday_delete(self): + """Should not be able delete a public holiday.""" + public_holiday = self.public_holidays[0] + + url = reverse('public-holiday-detail', args=[ + public_holiday.id + ]) + + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_public_holiday_year_filter(self): + """Should filter the public holidays by year.""" + year = self.public_holidays[0].date.strftime('%Y') + + url = '{0}?year={1}'.format(reverse('public-holiday-list'), year) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + expected = PublicHoliday.objects.filter(date__year=year) + + assert len(result['data']) == len(expected) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index ac5318bc4..9e9928ef2 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -1,103 +1,102 @@ -"""Tests for the users endpoint.""" +"""Tests for the locations endpoint.""" from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN) + HTTP_404_NOT_FOUND, + HTTP_405_METHOD_NOT_ALLOWED) from timed.employment.factories import UserFactory from timed.jsonapi_test_case import JSONAPITestCase class UserTests(JSONAPITestCase): - """Tests for the users endpoint. + """Tests for the user endpoint. - This endpoint should be read only. + This endpoint should be read only for normal users. """ def setUp(self): """Setup the environment for the tests.""" super().setUp() - self.users = UserFactory.create_batch(10) + self.users = UserFactory.create_batch(3) def test_user_list(self): - """Should respond with a list of users.""" + """Should respond with a list of one user: the currently logged in.""" url = reverse('user-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) - # 3 is the count of users which are created in the setup hook - assert len(result['data']) + len(self.users) + 3 + assert len(result['data']) == 1 + assert int(result['data'][0]['id']) == self.user.id - def test_user_detail(self): - """Should respond with a single user.""" - user = self.users[0] + def test_logged_in_user_detail(self): + """Should respond with a single user. + This should only work if it is the currently logged in user. + """ url = reverse('user-detail', args=[ - user.id + self.user.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_not_logged_in_user_detail(self): + """Should throw a 404 since we don't request the logged in user.""" + url = reverse('user-detail', args=[ + self.users[0].id ]) noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_404_NOT_FOUND def test_user_create(self): - """Should not be able to create a user.""" - data = {} - url = reverse('user-list') + """Should not be able to create a new user.""" + url = reverse('user-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - project_admin_res = self.project_admin_client.post(url, data) - system_admin_res = self.system_admin_client.post(url, data) + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_403_FORBIDDEN - assert system_admin_res.status_code == HTTP_403_FORBIDDEN + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_user_update(self): - """Should not be able to update a user.""" - user = self.users[1] - data = {} + """Should not be able to update an existing user.""" + user = self.users[0] url = reverse('user-detail', args=[ user.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - project_admin_res = self.project_admin_client.patch(url, data) - system_admin_res = self.system_admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_403_FORBIDDEN - assert system_admin_res.status_code == HTTP_403_FORBIDDEN + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_user_delete(self): - """Should not be able to delete a user.""" - user = self.users[1] - data = {} + """Should not be able delete a user.""" + user = self.users[0] url = reverse('user-detail', args=[ user.id ]) - noauth_res = self.noauth_client.delete(url, data) - user_res = self.client.delete(url, data) - project_admin_res = self.project_admin_client.delete(url, data) - system_admin_res = self.system_admin_client.delete(url, data) + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_403_FORBIDDEN - assert system_admin_res.status_code == HTTP_403_FORBIDDEN + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/urls.py b/timed/employment/urls.py index 4db3efed2..652ea95a3 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -7,6 +7,9 @@ r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'users', views.UserViewSet, 'user') +r.register(r'users', views.UserViewSet, 'user') +r.register(r'employments', views.EmploymentViewSet, 'employment') +r.register(r'locations', views.LocationViewSet, 'location') +r.register(r'public-holidays', views.PublicHolidayViewSet, 'public-holiday') urlpatterns = r.urls diff --git a/timed/employment/views.py b/timed/employment/views.py index 60de91a24..8570b40b9 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -3,12 +3,50 @@ from django.contrib.auth import get_user_model from rest_framework.viewsets import ReadOnlyModelViewSet -from timed.employment import filters, serializers +from timed.employment import filters, models, serializers class UserViewSet(ReadOnlyModelViewSet): """User view set.""" - queryset = get_user_model().objects.all() serializer_class = serializers.UserSerializer - filter_class = filters.UserFilterSet + + def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered users + :rtype: QuerySet + """ + return get_user_model().objects.filter(pk=self.request.user.pk) + + +class EmploymentViewSet(ReadOnlyModelViewSet): + """Employment view set.""" + + serializer_class = serializers.EmploymentSerializer + ordering = ('-start_date',) + + def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered employments + :rtype: QuerySet + """ + return models.Employment.objects.filter(user=self.request.user) + + +class LocationViewSet(ReadOnlyModelViewSet): + """Location viewset set.""" + + queryset = models.Location.objects.all() + serializer_class = serializers.LocationSerializer + ordering = ('name',) + + +class PublicHolidayViewSet(ReadOnlyModelViewSet): + """Public holiday view set.""" + + queryset = models.PublicHoliday.objects.all() + serializer_class = serializers.PublicHolidaySerializer + filter_class = filters.PublicHolidayFilterSet + ordering = ('date',) diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 9dd987d91..830e17439 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -3,7 +3,7 @@ import json import logging -from django.contrib.auth.models import Group, Permission, User +from django.contrib.auth.models import User from django.core.urlresolvers import reverse from rest_framework import status from rest_framework.test import APIClient, APITestCase @@ -85,17 +85,11 @@ def login(self, username, password): :raises: Exception """ data = { - 'data': { - 'type': 'obtain-json-web-tokens', - 'id': None, - 'attributes': { - 'username': username, - 'password': password - } - } + 'username': username, + 'password': password } - response = self.post(reverse('login'), data) + response = super().post(reverse('login'), data) if response.status_code != status.HTTP_200_OK: raise Exception('Wrong credentials!') # pragma: no cover @@ -111,103 +105,15 @@ def login(self, username, password): class JSONAPITestCase(APITestCase): """Base test case for testing the timed API.""" - def _get_system_admin_group_permissions(self): - return Permission.objects.filter(codename__in=[ - 'add_tasktemplate', - 'change_tasktemplate', - 'delete_tasktemplate', - ]) - - def _get_project_admin_group_permissions(self): - return Permission.objects.filter(codename__in=[ - 'add_customer', - 'change_customer', - 'delete_customer', - 'add_project', - 'change_project', - 'delete_project', - 'add_task', - 'change_task', - 'delete_task', - ]) - - def _get_user_group_permissions(self): - return Permission.objects.filter(codename__in=[ - 'add_activity', - 'change_activity', - 'delete_activity', - 'add_activityblock', - 'change_activityblock', - 'delete_activityblock', - 'add_attendance', - 'change_attendance', - 'delete_attendance', - 'add_report', - 'change_report', - 'delete_report', - ]) - - def _create_groups(self): - system_admin_group = Group.objects.create(name='System Admin') - project_admin_group = Group.objects.create(name='Project Admin') - user_group = Group.objects.create(name='User') - - system_admin_perms = self._get_system_admin_group_permissions() - project_admin_perms = self._get_project_admin_group_permissions() - user_perms = self._get_user_group_permissions() - - system_admin_group.permissions.add(*system_admin_perms) - project_admin_group.permissions.add(*project_admin_perms) - user_group.permissions.add(*user_perms) - - system_admin_group.save() - project_admin_group.save() - user_group.save() - - def _create_users(self): - self.system_admin_user = User.objects.create_user( - username='system_admin', - password='123qweasd' - ) - - self.project_admin_user = User.objects.create_user( - username='project_admin', - password='123qweasd' - ) + def setUp(self): + """Setup the clients for testing.""" + super().setUp() self.user = User.objects.create_user( username='user', password='123qweasd' ) - self.system_admin_user.groups.add(*Group.objects.filter(name__in=[ - 'System Admin', - 'Project Admin', - 'User' - ])) - - self.project_admin_user.groups.add(*Group.objects.filter(name__in=[ - 'Project Admin', - 'User' - ])) - - self.user.groups.add(*Group.objects.filter(name__in=[ - 'User' - ])) - - def setUp(self): - """Setup the clients for testing.""" - super().setUp() - - self._create_groups() - self._create_users() - - self.system_admin_client = JSONAPIClient() - self.system_admin_client.login('system_admin', '123qweasd') - - self.project_admin_client = JSONAPIClient() - self.project_admin_client.login('project_admin', '123qweasd') - self.client = JSONAPIClient() self.client.login('user', '123qweasd') diff --git a/timed/projects/factories.py b/timed/projects/factories.py index 96f0a904b..df713c439 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -24,10 +24,11 @@ class Meta: class ProjectFactory(DjangoModelFactory): """Project factory.""" - name = Faker('catch_phrase') - archived = False - comment = Faker('sentence') - customer = SubFactory('timed.projects.factories.CustomerFactory') + name = Faker('catch_phrase') + estimated_hours = Faker('random_int', min=0, max=2000) + archived = False + comment = Faker('sentence') + customer = SubFactory('timed.projects.factories.CustomerFactory') class Meta: """Meta informations for the project factory.""" diff --git a/timed/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py index 5a3886717..64cd64be4 100644 --- a/timed/projects/migrations/0001_initial.py +++ b/timed/projects/migrations/0001_initial.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-02-09 11:27 +# Generated by Django 1.10.4 on 2017-02-20 11:55 from __future__ import unicode_literals -from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -12,7 +11,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -34,11 +32,8 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=255)), ('comment', models.TextField(blank=True)), ('archived', models.BooleanField(default=False)), - ('tracker_type', models.CharField(blank=True, choices=[('GH', 'Github'), ('RM', 'Redmine')], max_length=2)), - ('tracker_name', models.CharField(blank=True, max_length=255)), - ('tracker_api_key', models.CharField(blank=True, max_length=255)), + ('estimated_hours', models.PositiveIntegerField(blank=True, null=True)), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.Customer')), - ('leaders', models.ManyToManyField(blank=True, related_name='projects', to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( diff --git a/timed/projects/models.py b/timed/projects/models.py index 1d08f67e7..bc20fc4da 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -1,6 +1,5 @@ """Models for the projects app.""" -from django.conf import settings from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver @@ -43,14 +42,9 @@ class Project(models.Model): name = models.CharField(max_length=255) comment = models.TextField(blank=True) archived = models.BooleanField(default=False) - tracker_type = models.CharField(max_length=2, choices=TYPES, blank=True) - tracker_name = models.CharField(max_length=255, blank=True) - tracker_api_key = models.CharField(max_length=255, blank=True) + estimated_hours = models.PositiveIntegerField(blank=True, null=True) customer = models.ForeignKey('projects.Customer', related_name='projects') - leaders = models.ManyToManyField(settings.AUTH_USER_MODEL, - related_name='projects', - blank=True) def __str__(self): """String representation. diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 4e60bf9af..13efa0c4e 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -1,6 +1,5 @@ """Serializers for the projects app.""" -from django.contrib.auth.models import User from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ModelSerializer @@ -35,15 +34,11 @@ class ProjectSerializer(ModelSerializer): """Project serializer.""" customer = ResourceRelatedField(queryset=models.Customer.objects.all()) - leaders = ResourceRelatedField(queryset=User.objects.all(), - required=False, - many=True) tasks = ResourceRelatedField(read_only=True, many=True) included_serializers = { 'customer': 'timed.projects.serializers.CustomerSerializer', - 'leaders': 'timed.employment.serializers.UserSerializer', 'tasks': 'timed.projects.serializers.TaskSerializer' } @@ -54,12 +49,9 @@ class Meta: fields = [ 'name', 'comment', + 'estimated_hours', 'archived', - 'tracker_type', - 'tracker_name', - 'tracker_api_key', 'customer', - 'leaders', 'tasks', ] diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 9f97388f4..a293706fe 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -1,9 +1,8 @@ """Tests for the customers endpoint.""" from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN) +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import CustomerFactory @@ -31,12 +30,12 @@ def test_customer_list(self): url = reverse('customer-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert len(result['data']) == len(self.customers) @@ -49,79 +48,45 @@ def test_customer_detail(self): ]) noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK def test_customer_create(self): - """Should create a new customer.""" - data = { - 'data': { - 'type': 'customers', - 'id': None, - 'attributes': { - 'name': 'Test customer', - 'email': 'foo@bar.ch' - } - } - } - + """Should not be able to create a new customer.""" url = reverse('customer-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - project_admin_res = self.project_admin_client.post(url, data) + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_201_CREATED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_customer_update(self): - """Should update an existing customer.""" - customer = self.customers[0] - - data = { - 'data': { - 'type': 'customers', - 'id': customer.id, - 'attributes': { - 'name': 'Test customer 2' - } - } - } + """Should not be able to update an existing customer.""" + customer = self.customers[0] url = reverse('customer-detail', args=[ customer.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - project_admin_res = self.project_admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_200_OK - - result = self.result(project_admin_res) - - assert ( - result['data']['attributes']['name'] == - data['data']['attributes']['name'] - ) + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_customer_delete(self): - """Should delete a customer.""" + """Should not be able delete a customer.""" customer = self.customers[0] url = reverse('customer-detail', args=[ customer.id ]) - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) - project_admin_res = self.project_admin_client.delete(url) + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_204_NO_CONTENT + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index f15497bd1..68aeca770 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,9 +1,8 @@ """Tests for the projects endpoint.""" from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN) +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import ProjectFactory, TaskTemplateFactory @@ -32,12 +31,12 @@ def test_project_list(self): url = reverse('project-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert len(result['data']) == len(self.projects) @@ -50,112 +49,48 @@ def test_project_detail(self): ]) noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK def test_project_create(self): - """Should create a new project.""" - customer = self.projects[1].customer - - data = { - 'data': { - 'type': 'projects', - 'id': None, - 'attributes': { - 'name': 'Test Project' - }, - 'relationships': { - 'customer': { - 'data': { - 'type': 'customers', - 'id': customer.id - } - } - } - } - } - + """Should not be able to create a new project.""" url = reverse('project-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - project_admin_res = self.project_admin_client.post(url, data) + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_201_CREATED - - result = self.result(project_admin_res) - - assert ( - int(result['data']['relationships']['customer']['data']['id']) == - int(data['data']['relationships']['customer']['data']['id']) - ) + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_project_update(self): - """Should update an existing project.""" + """Should not be able to update an existing project.""" project = self.projects[0] - customer = self.projects[1].customer - - data = { - 'data': { - 'type': 'projects', - 'id': project.id, - 'attributes': { - 'name': 'Test Project 2' - }, - 'relationships': { - 'customer': { - 'data': { - 'type': 'customers', - 'id': customer.id - } - } - } - } - } url = reverse('project-detail', args=[ project.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - project_admin_res = self.project_admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_200_OK - - result = self.result(project_admin_res) - - assert ( - result['data']['attributes']['name'] == - data['data']['attributes']['name'] - ) - - assert ( - int(result['data']['relationships']['customer']['data']['id']) == - int(data['data']['relationships']['customer']['data']['id']) - ) + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_project_delete(self): - """Should delete a project.""" + """Should not be able to delete a project.""" project = self.projects[0] url = reverse('project-detail', args=[ project.id ]) - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) - project_admin_res = self.project_admin_client.delete(url) + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_204_NO_CONTENT + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_project_default_tasks(self): """Should generate tasks based on task templates for a new project.""" diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index a28fd420d..558d9da86 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -1,9 +1,8 @@ """Tests for the tasks endpoint.""" from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN) +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import TaskFactory @@ -21,17 +20,19 @@ def setUp(self): self.tasks = TaskFactory.create_batch(5) + TaskFactory.create_batch(5, archived=True) + def test_task_list(self): """Should respond with a list of tasks.""" url = reverse('task-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert len(result['data']) == len(self.tasks) @@ -48,122 +49,51 @@ def test_task_detail(self): ]) noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert 'id' in result['data'] assert 'name' in result['data']['attributes'] assert 'project' in result['data']['relationships'] def test_task_create(self): - """Should create a new task.""" - project = self.tasks[0].project - - data = { - 'data': { - 'type': 'tasks', - 'id': None, - 'attributes': { - 'name': 'Test Task', - 'estimated-hours': 200, - 'archived': False - }, - 'relationships': { - 'project': { - 'data': { - 'type': 'projects', - 'id': project.id - } - } - } - } - } - + """Should not be able to create a task.""" url = reverse('task-list') - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) - project_admin_res = self.project_admin_client.post(url, data) + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_201_CREATED - - result = self.result(project_admin_res) - - assert not result['data']['id'] is None - - assert ( - result['data']['attributes']['name'] == - data['data']['attributes']['name'] - ) - assert ( - int(result['data']['relationships']['project']['data']['id']) == - int(data['data']['relationships']['project']['data']['id']) - ) + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_task_update(self): - """Should update an exisiting task.""" + """Should not be able to update an exisiting task.""" task = self.tasks[0] - project = self.tasks[1].project - - data = { - 'data': { - 'type': 'tasks', - 'id': task.id, - 'attributes': { - 'name': 'Test Task updated' - }, - 'relationships': { - 'project': { - 'data': { - 'type': 'projects', - 'id': project.id - } - } - } - } - } url = reverse('task-detail', args=[ task.id ]) - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) - project_admin_res = self.project_admin_client.patch(url, data) + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_200_OK - - result = self.result(project_admin_res) - - assert ( - result['data']['attributes']['name'] == - data['data']['attributes']['name'] - ) - assert ( - int(result['data']['relationships']['project']['data']['id']) == - int(data['data']['relationships']['project']['data']['id']) - ) + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED def test_task_delete(self): - """Should delete a task.""" + """Should not be able delete a task.""" task = self.tasks[0] url = reverse('task-detail', args=[ task.id ]) - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) - project_admin_res = self.project_admin_client.delete(url) + noauth_res = self.noauth_client.delete(url) + res = self.client.delete(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_403_FORBIDDEN - assert project_admin_res.status_code == HTTP_204_NO_CONTENT + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/projects/views.py b/timed/projects/views.py index 554ec74d2..b560bf2ac 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,11 +1,11 @@ """Viewsets for the projects app.""" -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ReadOnlyModelViewSet from timed.projects import filters, models, serializers -class CustomerViewSet(ModelViewSet): +class CustomerViewSet(ReadOnlyModelViewSet): """Customer view set.""" queryset = models.Customer.objects.filter(archived=False) @@ -15,7 +15,7 @@ class CustomerViewSet(ModelViewSet): ordering = 'name' -class ProjectViewSet(ModelViewSet): +class ProjectViewSet(ReadOnlyModelViewSet): """Project view set.""" queryset = models.Project.objects.filter(archived=False) @@ -25,9 +25,9 @@ class ProjectViewSet(ModelViewSet): ordering = ('customer__name', 'name') -class TaskViewSet(ModelViewSet): +class TaskViewSet(ReadOnlyModelViewSet): """Task view set.""" - queryset = models.Task.objects.all() + queryset = models.Task.objects.filter(archived=False) serializer_class = serializers.TaskSerializer filter_class = filters.TaskFilterSet diff --git a/timed/settings.py b/timed/settings.py index 83c44f0f6..631401e59 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -10,26 +10,12 @@ https://docs.djangoproject.com/en/1.9/ref/settings/ """ -import configparser import datetime import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -config = configparser.ConfigParser() -config.read(os.path.join(BASE_DIR, 'timed/config.ini')) - -ldap_config = config['ldap'] -github_config = config['github'] -redmine_config = config['redmine'] - - -def trueish(value): - """Cast a string to a boolean.""" - return value.lower() in ('true', '1', 'yes') - - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ @@ -41,7 +27,6 @@ def trueish(value): ALLOWED_HOSTS = ['*'] - # Application definition INSTALLED_APPS = [ @@ -158,12 +143,12 @@ def trueish(value): ), 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', + 'rest_framework.parsers.JSONParser', 'rest_framework.parsers.FormParser', 'rest_framework.parsers.MultiPartParser' ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', - 'rest_framework.permissions.DjangoModelPermissions', ), 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', @@ -178,6 +163,7 @@ def trueish(value): 'rest_framework_json_api.pagination.PageNumberPagination', 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', + 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', ), } @@ -199,24 +185,14 @@ def trueish(value): APPEND_SLASH = False -GITHUB_API_URL = github_config.get('GITHUB_API_URL') -GITHUB_ISSUE_URL = github_config.get('GITHUB_ISSUE_URL') - -REDMINE_API_URL = redmine_config.get('REDMINE_API_URL') -REDMINE_ISSUE_URL = redmine_config.get('REDMINE_ISSUE_URL') -REDMINE_BASIC_AUTH = trueish(redmine_config.get('REDMINE_BASIC_AUTH')) -REDMINE_BASIC_AUTH_USER = redmine_config.get('REDMINE_BASIC_AUTH_USER') -REDMINE_BASIC_AUTH_PASSWORD = redmine_config.get('REDMINE_BASIC_AUTH_PASSWORD') - -AUTH_LDAP_ALWAYS_UPDATE_USER = True - AUTH_LDAP_USER_ATTR_MAP = { 'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail' } -AUTH_LDAP_SERVER_URI = ldap_config.get('AUTH_LDAP_SERVER_URI') -AUTH_LDAP_BIND_DN = ldap_config.get('AUTH_LDAP_BIND_DN') -AUTH_LDAP_BIND_PASSWORD = ldap_config.get('AUTH_LDAP_BIND_PASSWORD') -AUTH_LDAP_USER_DN_TEMPLATE = ldap_config.get('AUTH_LDAP_USER_DN_TEMPLATE') +LDAP_BASE = 'dc=adsy-ext,dc=becs,dc=adfinis-sygroup,dc=ch' +AUTH_LDAP_SERVER_URI = 'ldap://localhost:389' +AUTH_LDAP_BIND_DN = 'uid=Administrator,cn=users,{0}'.format(LDAP_BASE) +AUTH_LDAP_PASSWORD = 'univention' +AUTH_LDAP_USER_DN_TEMPLATE = 'uid=%(user)s,cn=users,{0}'.format(LDAP_BASE) diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 6a4b85245..1d2c706d8 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -82,6 +82,7 @@ class ReportFactory(DjangoModelFactory): """Task factory.""" comment = Faker('sentence') + date = Faker('date') review = False nta = False task = SubFactory('timed.projects.factories.TaskFactory') diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index fed74f65f..20764ca26 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -103,7 +103,7 @@ class Meta: """Meta information for the attendance filter set.""" model = models.Attendance - fields = ['day', 'user'] + fields = ['day'] class ReportFilterSet(FilterSet): @@ -113,4 +113,4 @@ class Meta: """Meta information for the report filter set.""" model = models.Report - fields = ['user'] + fields = ['date'] diff --git a/timed/tracking/migrations/0001_initial.py b/timed/tracking/migrations/0001_initial.py index 2463c1c49..3922de4a6 100644 --- a/timed/tracking/migrations/0001_initial.py +++ b/timed/tracking/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-02-09 11:27 +# Generated by Django 1.10.4 on 2017-02-20 11:55 from __future__ import unicode_literals from django.conf import settings @@ -53,6 +53,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('comment', models.CharField(max_length=255)), + ('date', models.DateField()), ('duration', models.DurationField()), ('review', models.BooleanField(default=False)), ('nta', models.BooleanField(default=False)), diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 2bacd68eb..a57b80b2b 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -115,6 +115,7 @@ class Report(models.Model): """ comment = models.CharField(max_length=255) + date = models.DateField() duration = models.DurationField() review = models.BooleanField(default=False) nta = models.BooleanField(default=False) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 7b4133c65..38fc20119 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -13,8 +13,7 @@ class ActivitySerializer(ModelSerializer): duration = DurationField(read_only=True) task = ResourceRelatedField(queryset=Task.objects.all()) user = ResourceRelatedField(read_only=True) - blocks = ResourceRelatedField(read_only=True, - many=True,) + blocks = ResourceRelatedField(read_only=True, many=True) included_serializers = { 'blocks': 'timed.tracking.serializers.ActivityBlockSerializer', @@ -43,7 +42,7 @@ class ActivityBlockSerializer(ModelSerializer): activity = ResourceRelatedField(queryset=models.Activity.objects.all()) included_serializers = { - 'timed.activity': 'timed.tracking.serializers.ActivitySerializer' + 'activity': 'timed.tracking.serializers.ActivitySerializer' } class Meta: @@ -93,6 +92,7 @@ class Meta: model = models.Report fields = [ 'comment', + 'date', 'duration', 'review', 'nta', diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 3e821c267..412972b03 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -42,12 +42,12 @@ def test_activity_list(self): url = reverse('activity-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert len(result['data']) == len(self.activities) @@ -60,10 +60,10 @@ def test_activity_detail(self): ]) noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK def test_activity_create(self): """Should create a new activity and automatically set the user.""" @@ -90,12 +90,12 @@ def test_activity_create(self): url = reverse('activity-list') noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) + res = self.client.post(url, data) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_201_CREATED + assert res.status_code == HTTP_201_CREATED - result = self.result(user_res) + result = self.result(res) assert ( int(result['data']['relationships']['user']['data']['id']) == @@ -121,12 +121,12 @@ def test_activity_update(self): ]) noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) + res = self.client.patch(url, data) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert ( result['data']['attributes']['comment'] == @@ -142,10 +142,10 @@ def test_activity_delete(self): ]) noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) + res = self.client.delete(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_204_NO_CONTENT + assert res.status_code == HTTP_204_NO_CONTENT def test_activity_list_filter_active(self): """Should respond with a list of active activities.""" diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py index 5847fd542..57f0fa132 100644 --- a/timed/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -42,12 +42,12 @@ def test_activity_block_list(self): url = reverse('activity-block-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert len(result['data']) == len(self.activity_blocks) @@ -60,10 +60,10 @@ def test_activity_block_detail(self): ]) noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK def test_activity_block_create(self): """Should create a new activity block.""" @@ -88,12 +88,12 @@ def test_activity_block_create(self): url = reverse('activity-block-list') noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) + res = self.client.post(url, data) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_201_CREATED + assert res.status_code == HTTP_201_CREATED - result = self.result(user_res) + result = self.result(res) assert not result['data']['attributes']['from-datetime'] is None assert result['data']['attributes']['to-datetime'] is None @@ -118,12 +118,12 @@ def test_activity_block_update(self): ]) noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) + res = self.client.patch(url, data) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert ( result['data']['attributes']['to-datetime'] == @@ -139,7 +139,7 @@ def test_activity_delete(self): ]) noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) + res = self.client.delete(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_204_NO_CONTENT + assert res.status_code == HTTP_204_NO_CONTENT diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index 2a461f01f..cf44bf79f 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -38,12 +38,12 @@ def test_attendance_list(self): url = reverse('attendance-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert len(result['data']) == len(self.attendances) @@ -56,10 +56,10 @@ def test_attendance_detail(self): ]) noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK def test_attendance_create(self): """Should create a new attendance and automatically set the user.""" @@ -79,12 +79,12 @@ def test_attendance_create(self): url = reverse('attendance-list') noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) + res = self.client.post(url, data) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_201_CREATED + assert res.status_code == HTTP_201_CREATED - result = self.result(user_res) + result = self.result(res) assert not result['data']['id'] is None @@ -115,12 +115,12 @@ def test_attendance_update(self): ]) noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) + res = self.client.patch(url, data) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + assert res.status_code == HTTP_200_OK - result = self.result(user_res) + result = self.result(res) assert ( result['data']['attributes']['to-datetime'] == @@ -136,7 +136,7 @@ def test_attendance_delete(self): ]) noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) + res = self.client.delete(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_204_NO_CONTENT + assert res.status_code == HTTP_204_NO_CONTENT diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 7c0db817b..7b330c332 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -64,7 +64,8 @@ def test_report_create(self): 'id': None, 'attributes': { 'comment': 'foo', - 'duration': '00:50:00' + 'duration': '00:50:00', + 'date': '2017-02-01' }, 'relationships': { 'task': { @@ -107,7 +108,8 @@ def test_report_update(self): 'id': report.id, 'attributes': { 'comment': 'foobar', - 'duration': '01:00:00' + 'duration': '01:00:00', + 'date': '2017-02-04' }, 'relationships': { 'task': { @@ -139,6 +141,11 @@ def test_report_update(self): data['data']['attributes']['duration'] ) + assert ( + result['data']['attributes']['date'] == + data['data']['attributes']['date'] + ) + assert result['data']['relationships']['task']['data'] is None def test_report_delete(self): From f395cdb7cbec0c5c4957f07d059efa0e126f7bb3 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Feb 2017 16:58:05 +0100 Subject: [PATCH 049/980] Fix CI --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 57bd5a227..c958abe78 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,6 @@ install: - pip install coveralls before_script: - - cp timed/config.sample.ini timed/config.ini - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres script: make test From f4a0f51f967198c1abd6da53fcadd2410208d6b3 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 27 Feb 2017 13:49:34 +0100 Subject: [PATCH 050/980] * Validate employments on model (only used in admin) * Validate activity blocks on serializer --- timed/employment/models.py | 28 +++++++++++++ timed/employment/tests/test_employment.py | 28 ++++++++++++- timed/tracking/serializers.py | 46 +++++++++++++++++---- timed/tracking/tests/test_activity_block.py | 34 ++++++++++++++- timed/tracking/views.py | 21 ---------- 5 files changed, 125 insertions(+), 32 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 5c71ba055..8579ff5dd 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -1,6 +1,9 @@ """Models for the employment app.""" +import datetime + from django.conf import settings +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -39,6 +42,31 @@ class Employment(models.Model): start_date = models.DateField() end_date = models.DateField(blank=True, null=True) + def clean(self): + """Validate the employment as a whole. + + Ensure there are no overlapping employments per user and + only one active employment per user. + """ + super().clean() + + employments = Employment.objects.filter(user=self.user) + + if employments.filter(end_date__isnull=True) and self.end_date is None: + raise ValidationError('A user can only have one active employment') + + if any([ + e.start_date <= ( + self.end_date if self.end_date else datetime.date.today() + ) and self.start_date <= ( + e.end_date if e.end_date else datetime.date.today() + ) + for e + in employments + ]): + raise ValidationError('A user can\'t have multiple employments ' + 'at the same time') + def __str__(self): """String representation. diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index b78802448..86160cf1d 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -1,5 +1,9 @@ """Tests for the employments endpoint.""" +import datetime + +import pytest +from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) @@ -18,7 +22,13 @@ def setUp(self): """Setup the environment for the tests.""" super().setUp() - self.employments = EmploymentFactory.create_batch(2, user=self.user) + self.employments = [ + EmploymentFactory.create(user=self.user, + start_date=datetime.date(2010, 1, 1), + end_date=datetime.date(2015, 1, 1)), + EmploymentFactory.create(user=self.user, + start_date=datetime.date(2015, 1, 2)) + ] EmploymentFactory.create_batch(10) @@ -87,3 +97,19 @@ def test_employment_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_employment_unique_active(self): + """Should only be able to have one active employment per user.""" + e = EmploymentFactory.build(user=self.user, end_date=None) + + with pytest.raises(ValidationError): + e.clean() + + def test_employment_unique_range(self): + """Should only be able to have one employment at a time per user.""" + e = EmploymentFactory.build(user=self.user, + start_date=datetime.date(2009, 1, 1), + end_date=datetime.date(2016, 1, 1)) + + with pytest.raises(ValidationError): + e.clean() diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 38fc20119..94ce31f7e 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -1,7 +1,10 @@ """Serializers for the tracking app.""" from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import DurationField, ModelSerializer +from rest_framework_json_api.serializers import (CurrentUserDefault, + DurationField, + ModelSerializer, + ValidationError) from timed.projects.models import Task from timed.tracking import models @@ -11,14 +14,15 @@ class ActivitySerializer(ModelSerializer): """Activity serializer.""" duration = DurationField(read_only=True) + user = ResourceRelatedField(read_only=True, + default=CurrentUserDefault()) task = ResourceRelatedField(queryset=Task.objects.all()) - user = ResourceRelatedField(read_only=True) blocks = ResourceRelatedField(read_only=True, many=True) included_serializers = { 'blocks': 'timed.tracking.serializers.ActivityBlockSerializer', 'task': 'timed.projects.serializers.TaskSerializer', - 'user': 'timed.employment.serializers.UserSerializer' + 'user': 'timed.employment.serializers.UserSerializer', } class Meta: @@ -29,8 +33,8 @@ class Meta: 'comment', 'start_datetime', 'duration', - 'task', 'user', + 'task', 'blocks', ] @@ -42,9 +46,31 @@ class ActivityBlockSerializer(ModelSerializer): activity = ResourceRelatedField(queryset=models.Activity.objects.all()) included_serializers = { - 'activity': 'timed.tracking.serializers.ActivitySerializer' + 'activity': 'timed.tracking.serializers.ActivitySerializer', } + def validate(self, data): + """Validate the activity block. + + Ensure that a user can only have one activity with an active block. + """ + if self.instance: + user = self.instance.activity.user + to_datetime = data.get('to_datetime', self.instance.to_datetime) + else: + user = data.get('activity').user + to_datetime = data.get('to_datetime', None) + + blocks = models.ActivityBlock.objects.filter(activity__user=user) + + if ( + blocks.filter(to_datetime__isnull=True) and + to_datetime is None + ): + raise ValidationError('A user can only have one active activity') + + return data + class Meta: """Meta information for the activity block serializer.""" @@ -60,7 +86,11 @@ class Meta: class AttendanceSerializer(ModelSerializer): """Attendance serializer.""" - user = ResourceRelatedField(read_only=True) + user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) + + included_serializers = { + 'user': 'timed.employment.serializers.UserSerializer', + } class Meta: """Meta information for the attendance serializer.""" @@ -79,11 +109,11 @@ class ReportSerializer(ModelSerializer): task = ResourceRelatedField(queryset=Task.objects.all(), allow_null=True, required=False) - user = ResourceRelatedField(read_only=True) + user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) included_serializers = { 'task': 'timed.projects.serializers.TaskSerializer', - 'user': 'timed.employment.serializers.UserSerializer' + 'user': 'timed.employment.serializers.UserSerializer', } class Meta: diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py index 57f0fa132..3fcc72689 100644 --- a/timed/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -6,7 +6,8 @@ from django.core.urlresolvers import reverse from pytz import timezone from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) + HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED) from timed.jsonapi_test_case import JSONAPITestCase from timed.tracking.factories import ActivityBlockFactory, ActivityFactory @@ -130,7 +131,7 @@ def test_activity_block_update(self): data['data']['attributes']['to-datetime'] ) - def test_activity_delete(self): + def test_activity_block_delete(self): """Should delete an activity block.""" activity_block = self.activity_blocks[0] @@ -143,3 +144,32 @@ def test_activity_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_204_NO_CONTENT + + def test_activity_block_active_unique(self): + """Should not be able to have two active blocks.""" + block = self.activity_blocks[0] + + block.to_datetime = None + block.save() + + data = { + 'data': { + 'type': 'activity-blocks', + 'id': None, + 'attributes': {}, + 'relationships': { + 'activity': { + 'data': { + 'type': 'activities', + 'id': block.activity.id + } + } + } + } + } + + url = reverse('activity-block-list') + + res = self.client.post(url, data) + + assert res.status_code == HTTP_400_BAD_REQUEST diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 6fd05403e..ba0b5d810 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -19,13 +19,6 @@ def get_queryset(self): """ return models.Activity.objects.filter(user=self.request.user) - def perform_create(self, serializer): - """Set the user of the request as user on creation. - - :param ActivitySerializer seralizer: The serializer - """ - serializer.save(user=self.request.user) - class ActivityBlockViewSet(ModelViewSet): """Activity view set.""" @@ -58,13 +51,6 @@ def get_queryset(self): """ return models.Attendance.objects.filter(user=self.request.user) - def perform_create(self, serializer): - """Set the user of the request as user on creation. - - :param AttendanceSerializer seralizer: The serializer - """ - serializer.save(user=self.request.user) - class ReportViewSet(ModelViewSet): """Report view set.""" @@ -79,10 +65,3 @@ def get_queryset(self): :rtype: QuerySet """ return models.Report.objects.filter(user=self.request.user) - - def perform_create(self, serializer): - """Set the user of the request as user on creation. - - :param ReportSerializer seralizer: The serializer - """ - serializer.save(user=self.request.user) From 3adb8db68edc8fd3e43385ae5c89193d737b0bc3 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 2 Mar 2017 10:18:41 +0100 Subject: [PATCH 051/980] * Fixed some docstrings * Use a custom admin form for admin validation * Test custom admin form --- timed/employment/admin.py | 57 +++++++++++++++++++++++ timed/employment/factories.py | 4 +- timed/employment/models.py | 28 ----------- timed/employment/tests/test_employment.py | 21 +++++---- 4 files changed, 71 insertions(+), 39 deletions(-) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index cacc34aac..0864d3332 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -1,10 +1,66 @@ """Views for the admin interface.""" +import datetime + +from django import forms from django.contrib import admin +from django.core.exceptions import ValidationError from timed.employment import models +class EmploymentForm(forms.ModelForm): + """Custom form for the employment admin.""" + + def clean(self): + """Validate the employment as a whole. + + Ensure the end date is after the start date and there is only one + active employment per user and there are no overlapping employments. + + :throws: django.core.exceptions.ValidationError + :return: The cleaned data + :rtype: dict + """ + data = super().clean() + + employments = models.Employment.objects.filter(user=data.get('user')) + + if ( + data.get('end_date') and + data.get('start_date') >= data.get('end_date') + ): + raise ValidationError('The end date must be after the start date') + + if ( + employments.filter(end_date__isnull=True) and + data.get('end_date') is None + ): + raise ValidationError('A user can only have one active employment') + + if any([ + e.start_date <= ( + data.get('end_date') if + data.get('end_date') else + datetime.date.today() + ) and data.get('start_date') <= ( + e.end_date if e.end_date else datetime.date.today() + ) + for e + in employments + ]): + raise ValidationError('A user can\'t have multiple employments ' + 'at the same time') + + return data + + class Meta: + """Meta information for the employment form.""" + + fields = '__all__' + model = models.Employment + + @admin.register(models.Location) class LocationAdmin(admin.ModelAdmin): """Location admin view.""" @@ -17,6 +73,7 @@ class LocationAdmin(admin.ModelAdmin): class EmploymentAdmin(admin.ModelAdmin): """Employment admin view.""" + form = EmploymentForm list_display = ['__str__', 'percentage', 'location'] list_filter = ['location', 'user'] diff --git a/timed/employment/factories.py b/timed/employment/factories.py index 31c0fe349..1e3038d24 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -72,8 +72,8 @@ class EmploymentFactory(DjangoModelFactory): def worktime_per_day(self): """Generate the worktime per day based on the percentage. - :return The generated worktime - :rtype datetime.timedelta + :return: The generated worktime + :rtype: datetime.timedelta """ return datetime.timedelta(minutes=60 * 8.5 * self.percentage) diff --git a/timed/employment/models.py b/timed/employment/models.py index 8579ff5dd..5c71ba055 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -1,9 +1,6 @@ """Models for the employment app.""" -import datetime - from django.conf import settings -from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -42,31 +39,6 @@ class Employment(models.Model): start_date = models.DateField() end_date = models.DateField(blank=True, null=True) - def clean(self): - """Validate the employment as a whole. - - Ensure there are no overlapping employments per user and - only one active employment per user. - """ - super().clean() - - employments = Employment.objects.filter(user=self.user) - - if employments.filter(end_date__isnull=True) and self.end_date is None: - raise ValidationError('A user can only have one active employment') - - if any([ - e.start_date <= ( - self.end_date if self.end_date else datetime.date.today() - ) and self.start_date <= ( - e.end_date if e.end_date else datetime.date.today() - ) - for e - in employments - ]): - raise ValidationError('A user can\'t have multiple employments ' - 'at the same time') - def __str__(self): """String representation. diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 86160cf1d..1cda29ba1 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -3,11 +3,11 @@ import datetime import pytest -from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) +from timed.employment.admin import EmploymentForm from timed.employment.factories import EmploymentFactory from timed.jsonapi_test_case import JSONAPITestCase @@ -100,16 +100,19 @@ def test_employment_delete(self): def test_employment_unique_active(self): """Should only be able to have one active employment per user.""" - e = EmploymentFactory.build(user=self.user, end_date=None) + form = EmploymentForm({ + 'end_date': None + }, instance=self.employments[1]) - with pytest.raises(ValidationError): - e.clean() + with pytest.raises(ValueError): + form.save() def test_employment_unique_range(self): """Should only be able to have one employment at a time per user.""" - e = EmploymentFactory.build(user=self.user, - start_date=datetime.date(2009, 1, 1), - end_date=datetime.date(2016, 1, 1)) + form = EmploymentForm({ + 'start_date': datetime.date(2009, 1, 1), + 'end_date': datetime.date(2016, 1, 1) + }, instance=self.employments[0]) - with pytest.raises(ValidationError): - e.clean() + with pytest.raises(ValueError): + form.save() From 70a15d9a7bb231b7dda388c02b877a32ec44d807 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 2 Mar 2017 14:01:34 +0100 Subject: [PATCH 052/980] Simplified some conditions and translated custom validation messages. --- timed/employment/admin.py | 20 +++++++++++------- timed/locale/en/LC_MESSAGES/django.po | 30 +++++++++++++++++++++++++++ timed/settings.py | 8 +++++-- 3 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 timed/locale/en/LC_MESSAGES/django.po diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 0864d3332..3ecad273f 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -5,6 +5,7 @@ from django import forms from django.contrib import admin from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ from timed.employment import models @@ -30,27 +31,32 @@ def clean(self): data.get('end_date') and data.get('start_date') >= data.get('end_date') ): - raise ValidationError('The end date must be after the start date') + raise ValidationError(_( + 'The end date must be after the start date' + )) if ( employments.filter(end_date__isnull=True) and data.get('end_date') is None ): - raise ValidationError('A user can only have one active employment') + raise ValidationError(_( + 'A user can only have one active employment' + )) if any([ e.start_date <= ( - data.get('end_date') if - data.get('end_date') else + data.get('end_date') or datetime.date.today() ) and data.get('start_date') <= ( - e.end_date if e.end_date else datetime.date.today() + e.end_date or + datetime.date.today() ) for e in employments ]): - raise ValidationError('A user can\'t have multiple employments ' - 'at the same time') + raise ValidationError(_( + 'A user can\'t have multiple employments at the same time' + )) return data diff --git a/timed/locale/en/LC_MESSAGES/django.po b/timed/locale/en/LC_MESSAGES/django.po new file mode 100644 index 000000000..e30af8af1 --- /dev/null +++ b/timed/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,30 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-03-02 13:58+0100\n" +"PO-Revision-Date: 2017-03-02 13:59+0100\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Last-Translator: \n" +"Language-Team: \n" +"X-Generator: Poedit 1.8.11\n" + +#: timed/employment/admin.py:35 +msgid "The end date must be after the start date" +msgstr "The end date must be after the start date" + +#: timed/employment/admin.py:43 +msgid "A user can only have one active employment" +msgstr "A user can only have one active employment" + +#: timed/employment/admin.py:58 +msgid "A user can't have multiple employments at the same time" +msgstr "A user can't have multiple employments at the same time" diff --git a/timed/settings.py b/timed/settings.py index 631401e59..974f9e5b8 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -113,12 +113,16 @@ # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'timed/locale') +] + LANGUAGE_CODE = 'en-US' TIME_ZONE = 'Europe/Zurich' -USE_I18N = False -USE_L10N = False +USE_I18N = True +USE_L10N = True DATETIME_FORMAT = 'd.m.Y H:i:s' DATE_FORMAT = 'd.m.Y' From 915800d43f49d62af302c955c12c357f52281d7c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 23 Mar 2017 16:25:56 +0100 Subject: [PATCH 053/980] Implemented API functionality for the IPA --- .../migrations/0002_auto_20170323_1031.py | 52 +++++ timed/employment/models.py | 45 ++++ timed/employment/serializers.py | 195 +++++++++++++++++- timed/employment/urls.py | 11 +- timed/employment/views.py | 36 ++++ .../migrations/0002_auto_20170323_1031.py | 32 +++ timed/tracking/models.py | 44 +++- timed/tracking/serializers.py | 34 ++- 8 files changed, 427 insertions(+), 22 deletions(-) create mode 100644 timed/employment/migrations/0002_auto_20170323_1031.py create mode 100644 timed/tracking/migrations/0002_auto_20170323_1031.py diff --git a/timed/employment/migrations/0002_auto_20170323_1031.py b/timed/employment/migrations/0002_auto_20170323_1031.py new file mode 100644 index 000000000..d306d7349 --- /dev/null +++ b/timed/employment/migrations/0002_auto_20170323_1031.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-03-23 09:31 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('employment', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AbsenceCredit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('duration', models.DurationField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='AbsenceType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ], + ), + migrations.CreateModel( + name='OvertimeCredit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('duration', models.DurationField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='overtime_credits', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='absencecredit', + name='absence_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.AbsenceType'), + ), + migrations.AddField( + model_name='absencecredit', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 5c71ba055..be9a2a1c1 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -71,3 +71,48 @@ def __str__(self): :rtype: str """ return '{0} {1}'.format(self.name, self.date.strftime('%Y')) + + +class AbsenceType(models.Model): + """Absence type model. + + An absence type defines the type of an absence. E.g sickness, holiday or + school. + """ + + name = models.CharField(max_length=50) + + def __str__(self): + """String representation. + + :return: The string representation + :rtype: str + """ + return self.name + + +class AbsenceCredit(models.Model): + """Absence credit model. + + An absence credit is a credit for an absence of a certain type. An absence + is a report marked as an absence by referencing an absence type. + """ + + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='absence_credits') + absence_type = models.ForeignKey(AbsenceType) + date = models.DateField() + duration = models.DurationField(blank=True, null=True) + + +class OvertimeCredit(models.Model): + """Overtime credit model. + + An overtime credit is a transferred overtime from the last year. This is + added to the worktime of a user. + """ + + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='overtime_credits') + date = models.DateField() + duration = models.DurationField(blank=True, null=True) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 76803d016..630eea45f 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -1,16 +1,99 @@ """Serializers for the employment app.""" +from datetime import date, timedelta + +from dateutil import rrule from django.contrib.auth import get_user_model +from django.utils.duration import duration_string from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import ModelSerializer +from rest_framework_json_api.serializers import (ModelSerializer, + SerializerMethodField) from timed.employment import models +from timed.tracking.models import Report class UserSerializer(ModelSerializer): """User serializer.""" - employments = ResourceRelatedField(many=True, read_only=True) + employments = ResourceRelatedField(many=True, read_only=True) + worktime_balance = SerializerMethodField() + + def get_worktime_balance_raw(self, instance): + """Calculate the worktime balance for the user. + + 1. Determine the current employment of the user + 2. Take the latest of those two as start date: + * The start of the year + * The start of the current employment + 3. Take the delivered date if given or the current date as end date + 4. Determine the count of workdays within start and end date + 5. Determine the count of public holidays within start and end date + 6. The expected worktime consists of following elements: + * Workdays + * Subtracted by holidays + * Multiplicated with the worktime per day of the employment + 7. Determine the overtime credit duration within start and end date + 8. The reported worktime is the sum of the durations of all reports for + this user within start and end date + 9. The balance is the reported time plus the overtime credit minus the + expected worktime + + :returns: The worktime balance of the user + :rtype: datetime.timedelta + """ + employment = models.Employment.objects.get( + user=instance, + end_date__isnull=True + ) + + start_date = max(employment.start_date, date(date.today().year, 1, 1)) + end_date = date.today() # TODO + + workdays = rrule.rrule( + rrule.DAILY, + dtstart=start_date, + until=end_date, + byweekday=[1, 2, 3, 4, 5] + ).count() + + holidays = models.PublicHoliday.objects.filter( + location=employment.location, + date__gte=start_date, + date__lte=end_date + ).count() + + expected_worktime = employment.worktime_per_day * (workdays - holidays) + + overtime_credit = sum( + models.OvertimeCredit.objects.filter( + user=instance, + date__gte=start_date, + date__lte=end_date + ).values_list('duration', flat=True), + timedelta() + ) + + reported_worktime = sum( + Report.objects.filter( + user=instance, + date__gte=start_date, + date__lte=end_date + ).values_list('duration', flat=True), + timedelta() + ) + + return reported_worktime + overtime_credit - expected_worktime + + def get_worktime_balance(self, instance): + """The formatted worktime balance. + + :return: The formatted worktime balance. + :rtype: str + """ + worktime_balance = self.get_worktime_balance_raw(instance) + + return duration_string(worktime_balance) included_serializers = { 'employments': 'timed.employment.serializers.EmploymentSerializer' @@ -26,6 +109,7 @@ class Meta: 'last_name', 'email', 'employments', + 'worktime_balance', ] @@ -82,3 +166,110 @@ class Meta: 'date', 'location', ] + + +class AbsenceTypeSerializer(ModelSerializer): + """Absence type serializer.""" + + class Meta: + """Meta information for the absence type serializer.""" + + model = models.AbsenceType + fields = ['name'] + + +class AbsenceCreditSerializer(ModelSerializer): + """Absence credit serializer.""" + + absence_type = ResourceRelatedField(read_only=True) + user = ResourceRelatedField(read_only=True) + used = SerializerMethodField() + balance = SerializerMethodField() + + def get_used_raw(self, instance): + """The total of used time since the date of the requested credit. + + This is the sum of all durations of reports, which are assigned to the + credits user, absence type and were created at or after the date of + this credit. + + :return: The total of used time + :rtype: datetime.timedelta + """ + reports = Report.objects.filter( + user=instance.user, + absence_type=instance.absence_type, + date__gte=instance.date + ).values_list('duration', flat=True) + + return sum(reports, timedelta()) + + def get_balance_raw(self, instance): + """The balance of the requested credit. + + This is the difference between the credits duration and the total used + time. + + :return: The balance + :rtype: datetime.timedelta + """ + return ( + instance.duration - self.get_used_raw(instance) + if instance.duration + else None + ) + + def get_used(self, instance): + """The formatted total of used time. + + :return: The formatted total of used time + :rtype: str + """ + used = self.get_used_raw(instance) + + return duration_string(used) + + def get_balance(self, instance): + """The formatted balance. + + This is None if we don't have a duration. + + :return: The formatted balance + :rtype: str or None + """ + balance = self.get_balance_raw(instance) + + return duration_string(balance) if balance else None + + included_serializers = { + 'absence_type': 'timed.employment.serializers.AbsenceTypeSerializer' + } + + class Meta: + """Meta information for the absence credit serializer.""" + + model = models.AbsenceCredit + fields = [ + 'user', + 'absence_type', + 'date', + 'duration', + 'used', + 'balance', + ] + + +class OvertimeCreditSerializer(ModelSerializer): + """Overtime credit serializer.""" + + user = ResourceRelatedField(read_only=True) + + class Meta: + """Meta information for the overtime credit serializer.""" + + model = models.OvertimeCredit + fields = [ + 'user', + 'date', + 'duration' + ] diff --git a/timed/employment/urls.py b/timed/employment/urls.py index 652ea95a3..b9d782754 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -7,9 +7,12 @@ r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'users', views.UserViewSet, 'user') -r.register(r'employments', views.EmploymentViewSet, 'employment') -r.register(r'locations', views.LocationViewSet, 'location') -r.register(r'public-holidays', views.PublicHolidayViewSet, 'public-holiday') +r.register(r'users', views.UserViewSet, 'user') +r.register(r'employments', views.EmploymentViewSet, 'employment') +r.register(r'locations', views.LocationViewSet, 'location') +r.register(r'public-holidays', views.PublicHolidayViewSet, 'public-holiday') +r.register(r'absence-types', views.AbsenceTypeViewSet, 'absence-type') +r.register(r'absence-credits', views.AbsenceCreditViewSet, 'absence-credit') +r.register(r'overtime-credits', views.OvertimeCreditViewSet, 'overtime-credit') urlpatterns = r.urls diff --git a/timed/employment/views.py b/timed/employment/views.py index 8570b40b9..d7dadbe93 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -50,3 +50,39 @@ class PublicHolidayViewSet(ReadOnlyModelViewSet): serializer_class = serializers.PublicHolidaySerializer filter_class = filters.PublicHolidayFilterSet ordering = ('date',) + + +class AbsenceTypeViewSet(ReadOnlyModelViewSet): + """Absence type view set.""" + + queryset = models.AbsenceType.objects.all() + serializer_class = serializers.AbsenceTypeSerializer + ordering = ('name',) + + +class AbsenceCreditViewSet(ReadOnlyModelViewSet): + """Absence type view set.""" + + serializer_class = serializers.AbsenceCreditSerializer + + def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered absence credits + :rtype: QuerySet + """ + return models.AbsenceCredit.objects.filter(user=self.request.user) + + +class OvertimeCreditViewSet(ReadOnlyModelViewSet): + """Absence type view set.""" + + serializer_class = serializers.OvertimeCreditSerializer + + def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered overtime credits + :rtype: QuerySet + """ + return models.OvertimeCredit.objects.filter(user=self.request.user) diff --git a/timed/tracking/migrations/0002_auto_20170323_1031.py b/timed/tracking/migrations/0002_auto_20170323_1031.py new file mode 100644 index 000000000..2baf64202 --- /dev/null +++ b/timed/tracking/migrations/0002_auto_20170323_1031.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-03-23 09:31 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0002_auto_20170323_1031'), + ('tracking', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='report', + old_name='nta', + new_name='not_billable', + ), + migrations.AddField( + model_name='report', + name='absence_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='employment.AbsenceType'), + ), + migrations.AddField( + model_name='report', + name='activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='tracking.Activity'), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index a57b80b2b..1ccc5c402 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -114,17 +114,39 @@ class Report(models.Model): bill for the customer. """ - comment = models.CharField(max_length=255) - date = models.DateField() - duration = models.DurationField() - review = models.BooleanField(default=False) - nta = models.BooleanField(default=False) - task = models.ForeignKey('projects.Task', - null=True, - blank=True, - related_name='reports') - user = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name='reports') + comment = models.CharField(max_length=255, blank=True) + date = models.DateField() + duration = models.DurationField() + review = models.BooleanField(default=False) + not_billable = models.BooleanField(default=False) + task = models.ForeignKey('projects.Task', + null=True, + blank=True, + related_name='reports') + activity = models.ForeignKey(Activity, + null=True, + blank=True, + related_name='reports') + absence_type = models.ForeignKey('employment.AbsenceType', + null=True, + blank=True, + related_name='reports') + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='reports') + + def save(self, *args, **kwargs): + """Customized save method. + + This rounds the duration of the report to the nearest 15 minutes. + + :returns: The saved report + :rtype: timed.tracking.models.Report + """ + self.duration = timedelta( + seconds=round(self.duration.seconds / (15 * 60)) * (15 * 60) + ) + + return super().save(*args, **kwargs) def __str__(self): """String representation. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 94ce31f7e..f43655416 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -6,6 +6,7 @@ ModelSerializer, ValidationError) +from timed.employment.models import AbsenceType from timed.projects.models import Task from timed.tracking import models @@ -106,10 +107,31 @@ class Meta: class ReportSerializer(ModelSerializer): """Report serializer.""" - task = ResourceRelatedField(queryset=Task.objects.all(), - allow_null=True, - required=False) - user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) + task = ResourceRelatedField(queryset=Task.objects.all(), + allow_null=True, + required=False) + absence_type = ResourceRelatedField(queryset=AbsenceType.objects.all(), + allow_null=True, + required=False) + activity = ResourceRelatedField(queryset=models.Activity.objects.all(), + allow_null=True, + required=False) + user = ResourceRelatedField(read_only=True, + default=CurrentUserDefault()) + + def validate(self, data): + """Validate the report. + + Check if the report has either a task or an absence type. + + :return: The validated data + :rtype: dict + """ + if not data.get('task') and not data.get('absence_type'): + raise ValidationError('Either a task or a absence type ' + 'must be referenced.') + + return data included_serializers = { 'task': 'timed.projects.serializers.TaskSerializer', @@ -125,7 +147,9 @@ class Meta: 'date', 'duration', 'review', - 'nta', + 'not_billable', 'task', + 'absence_type', + 'activity', 'user', ] From 6d01edf3e40462095a3d1b54b10adebd4c056443 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 23 Mar 2017 16:26:22 +0100 Subject: [PATCH 054/980] Added missing admin views --- timed/employment/admin.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 3ecad273f..d784e5b89 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -90,3 +90,26 @@ class PublicHolidayAdmin(admin.ModelAdmin): list_display = ['__str__', 'date', 'location'] list_filter = ['location'] + + +@admin.register(models.AbsenceType) +class AbsenceTypeAdmin(admin.ModelAdmin): + """Absence type admin view.""" + + list_display = ['name'] + + +@admin.register(models.AbsenceCredit) +class AbsenceCreditAdmin(admin.ModelAdmin): + """Absence credit admin view.""" + + list_display = ['absence_type', 'user', 'duration', 'date'] + list_filter = ['absence_type', 'user'] + + +@admin.register(models.OvertimeCredit) +class OvertimeCreditAdmin(admin.ModelAdmin): + """Overtime credit admin view.""" + + list_display = ['user', 'duration', 'date'] + list_filter = ['user'] From 69f856157011b26f2fa4c313afe2e8b2368c00db Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Mar 2017 10:31:37 +0100 Subject: [PATCH 055/980] Updated factories for tests --- timed/employment/factories.py | 55 +++++++++++++++++++++++++++++++++++ timed/tracking/factories.py | 13 +++++---- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/timed/employment/factories.py b/timed/employment/factories.py index 1e3038d24..e4604d492 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -1,6 +1,7 @@ """Factories for testing the tracking app.""" import datetime +import random from django.conf import settings from factory import Faker, SubFactory, lazy_attribute @@ -81,3 +82,57 @@ class Meta: """Meta informations for the employment factory.""" model = models.Employment + + +class AbsenceTypeFactory(DjangoModelFactory): + """Absence type factory.""" + + name = Faker('word') + + class Meta: + """Meta informations for the absence type factory.""" + + model = models.AbsenceType + + +class AbsenceCreditFactory(DjangoModelFactory): + """Absence credit factory.""" + + absence_type = SubFactory(AbsenceTypeFactory) + user = SubFactory(UserFactory) + date = Faker('date_object') + + @lazy_attribute + def duration(self): + """Generate a random duration. + + :return: The generated duration + :rtype: datetime.timedelta + """ + return datetime.timedelta(hours=random.randint(8, 200)) + + class Meta: + """Meta informations for the absence credit factory.""" + + model = models.AbsenceCredit + + +class OvertimeCreditFactory(DjangoModelFactory): + """Overtime credit factory.""" + + user = SubFactory(UserFactory) + date = Faker('date_object') + + @lazy_attribute + def duration(self): + """Generate a random duration. + + :return: The generated duration + :rtype: datetime.timedelta + """ + return datetime.timedelta(hours=random.randint(5, 40)) + + class Meta: + """Meta informations for the overtime credit factory.""" + + model = models.OvertimeCredit diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 1d2c706d8..acf139ba7 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -81,12 +81,13 @@ class Meta: class ReportFactory(DjangoModelFactory): """Task factory.""" - comment = Faker('sentence') - date = Faker('date') - review = False - nta = False - task = SubFactory('timed.projects.factories.TaskFactory') - user = SubFactory('timed.employment.factories.UserFactory') + comment = Faker('sentence') + date = Faker('date') + review = False + not_billable = False + absence_type = None + task = SubFactory('timed.projects.factories.TaskFactory') + user = SubFactory('timed.employment.factories.UserFactory') @lazy_attribute def duration(self): From 4a127aa4547dd8fd07b7e19167922f5d675d41ba Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Mar 2017 10:32:32 +0100 Subject: [PATCH 056/980] Added end date as query param for balance calculations --- timed/employment/serializers.py | 23 ++++++++++++++++++++--- timed/employment/views.py | 15 ++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 630eea45f..b124eeeaa 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -1,6 +1,6 @@ """Serializers for the employment app.""" -from datetime import date, timedelta +from datetime import date, datetime, timedelta from dateutil import rrule from django.contrib.auth import get_user_model @@ -47,8 +47,15 @@ def get_worktime_balance_raw(self, instance): end_date__isnull=True ) + request = self.context.get('request') + requested_end_date = request.query_params.get('until') + start_date = max(employment.start_date, date(date.today().year, 1, 1)) - end_date = date.today() # TODO + end_date = ( + datetime.strptime(requested_end_date, '%Y-%m-%d').date() + if requested_end_date + else date.today() + ) workdays = rrule.rrule( rrule.DAILY, @@ -196,10 +203,20 @@ def get_used_raw(self, instance): :return: The total of used time :rtype: datetime.timedelta """ + request = self.context.get('request') + requested_end_date = request.query_params.get('until') + + end_date = ( + datetime.strptime(requested_end_date, '%Y-%m-%d').date() + if requested_end_date + else date.today() + ) + reports = Report.objects.filter( user=instance.user, absence_type=instance.absence_type, - date__gte=instance.date + date__gte=instance.date, + date__lte=end_date ).values_list('duration', flat=True) return sum(reports, timedelta()) diff --git a/timed/employment/views.py b/timed/employment/views.py index d7dadbe93..89f5c9e15 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -1,5 +1,7 @@ """Viewsets for the employment app.""" +from datetime import date, datetime + from django.contrib.auth import get_user_model from rest_framework.viewsets import ReadOnlyModelViewSet @@ -71,7 +73,18 @@ def get_queryset(self): :return: The filtered absence credits :rtype: QuerySet """ - return models.AbsenceCredit.objects.filter(user=self.request.user) + requested_end_date = self.request.query_params.get('until') + + end_date = ( + datetime.strptime(requested_end_date, '%Y-%m-%d').date() + if requested_end_date + else date.today() + ) + + return models.AbsenceCredit.objects.filter( + user=self.request.user, + date__lte=end_date + ) class OvertimeCreditViewSet(ReadOnlyModelViewSet): From ed7788b6dca87b97bc0cb6202bca5f6c9553249c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Mar 2017 10:32:56 +0100 Subject: [PATCH 057/980] Added tests for the IPA functionality --- timed/employment/tests/test_absence_credit.py | 197 ++++++++++++++++++ timed/employment/tests/test_absence_type.py | 87 ++++++++ .../employment/tests/test_overtime_credit.py | 92 ++++++++ timed/employment/tests/test_user.py | 95 ++++++++- timed/tracking/tests/test_report.py | 62 +++++- 5 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 timed/employment/tests/test_absence_credit.py create mode 100644 timed/employment/tests/test_absence_type.py create mode 100644 timed/employment/tests/test_overtime_credit.py diff --git a/timed/employment/tests/test_absence_credit.py b/timed/employment/tests/test_absence_credit.py new file mode 100644 index 000000000..42b78fef9 --- /dev/null +++ b/timed/employment/tests/test_absence_credit.py @@ -0,0 +1,197 @@ +"""Tests for the absence credits endpoint.""" + +from datetime import timedelta + +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) + +from timed.employment.factories import AbsenceCreditFactory +from timed.jsonapi_test_case import JSONAPITestCase +from timed.tracking.factories import ReportFactory + + +class AbsenceCreditTests(JSONAPITestCase): + """Tests for the absence credits endpoint. + + This endpoint should be read only for normal users. + """ + + def setUp(self): + """Setup the environment for the tests.""" + super().setUp() + + self.absence_credits = AbsenceCreditFactory.create_batch( + 5, + user=self.user + ) + + AbsenceCreditFactory.create_batch(5) + + def test_absence_credit_list(self): + """Should respond with a list of absence credits.""" + url = reverse('absence-credit-list') + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == len(self.absence_credits) + + def test_absence_credit_detail(self): + """Should respond with a single absence credit.""" + absence_credit = self.absence_credits[0] + + url = reverse('absence-credit-detail', args=[ + absence_credit.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_absence_credit_create(self): + """Should not be able to create a new absence credit.""" + url = reverse('absence-credit-list') + + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_absence_credit_update(self): + """Should not be able to update an existing absence credit.""" + absence_credit = self.absence_credits[0] + + url = reverse('absence-credit-detail', args=[ + absence_credit.id + ]) + + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_absence_credit_delete(self): + """Should not be able delete an absence credit.""" + absence_credit = self.absence_credits[0] + + url = reverse('absence-credit-detail', args=[ + absence_credit.id + ]) + + noauth_res = self.noauth_client.delete(url) + res = self.client.delete(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_absence_credit_balance(self): + """Should calculate an absence credit balance.""" + absence_credit = AbsenceCreditFactory.create( + user=self.user, + duration=timedelta(hours=30) + ) + + ReportFactory.create_batch( + 2, + user=self.user, + absence_type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=1), + duration=timedelta(hours=8) + ) + + ReportFactory.create( + user=self.user, + absence_type=absence_credit.absence_type, + date=absence_credit.date - timedelta(days=1), + duration=timedelta(hours=8) + ) + + url = reverse('absence-credit-detail', args=[ + absence_credit.id + ]) + + res = self.client.get(url) + result = self.result(res) + + assert result['data']['attributes']['duration'] == '1 06:00:00' + assert result['data']['attributes']['used'] == '16:00:00' + assert result['data']['attributes']['balance'] == '14:00:00' + + def test_absence_credit_balance_no_duration(self): + """Should not calculate an absence credit balance.""" + absence_credit = AbsenceCreditFactory.create( + user=self.user, + duration=None + ) + + ReportFactory.create( + user=self.user, + absence_type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=1), + duration=timedelta(hours=8) + ) + + url = reverse('absence-credit-detail', args=[ + absence_credit.id + ]) + + res = self.client.get(url) + result = self.result(res) + + assert result['data']['attributes']['duration'] is None + assert result['data']['attributes']['used'] == '08:00:00' + assert result['data']['attributes']['balance'] is None + + def test_absence_credit_balance_until(self): + """Should calculate a correct absence credit balance.""" + absence_credit = AbsenceCreditFactory.create( + user=self.user, + duration=timedelta(hours=30) + ) + + ReportFactory.create_batch( + 2, + user=self.user, + absence_type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=1), + duration=timedelta(hours=8) + ) + + ReportFactory.create( + user=self.user, + absence_type=absence_credit.absence_type, + date=absence_credit.date - timedelta(days=1), + duration=timedelta(hours=8) + ) + + ReportFactory.create( + user=self.user, + absence_type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=2), + duration=timedelta(hours=8) + ) + + url = reverse('absence-credit-detail', args=[ + absence_credit.id + ]) + + res = self.client.get('{0}?until={1}'.format( + url, + (absence_credit.date + timedelta(days=1)).strftime('%Y-%m-%d') + )) + + result = self.result(res) + + assert result['data']['attributes']['duration'] == '1 06:00:00' + assert result['data']['attributes']['used'] == '16:00:00' + assert result['data']['attributes']['balance'] == '14:00:00' diff --git a/timed/employment/tests/test_absence_type.py b/timed/employment/tests/test_absence_type.py new file mode 100644 index 000000000..62bbd56b6 --- /dev/null +++ b/timed/employment/tests/test_absence_type.py @@ -0,0 +1,87 @@ +"""Tests for the absence types endpoint.""" + +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) + +from timed.employment.factories import AbsenceTypeFactory +from timed.jsonapi_test_case import JSONAPITestCase + + +class AbsenceTypeTests(JSONAPITestCase): + """Tests for the absence types endpoint. + + This endpoint should be read only for normal users. + """ + + def setUp(self): + """Setup the environment for the tests.""" + super().setUp() + + self.absence_types = AbsenceTypeFactory.create_batch(5) + + def test_absence_type_list(self): + """Should respond with a list of absence types.""" + url = reverse('absence-type-list') + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == len(self.absence_types) + + def test_absence_type_detail(self): + """Should respond with a single absence type.""" + absence_type = self.absence_types[0] + + url = reverse('absence-type-detail', args=[ + absence_type.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_absence_type_create(self): + """Should not be able to create a new absence type.""" + url = reverse('absence-type-list') + + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_absence_type_update(self): + """Should not be able to update an existing absence type.""" + absence_type = self.absence_types[0] + + url = reverse('absence-type-detail', args=[ + absence_type.id + ]) + + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_absence_type_delete(self): + """Should not be able delete an absence type.""" + absence_type = self.absence_types[0] + + url = reverse('absence-type-detail', args=[ + absence_type.id + ]) + + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_overtime_credit.py b/timed/employment/tests/test_overtime_credit.py new file mode 100644 index 000000000..aaeff250f --- /dev/null +++ b/timed/employment/tests/test_overtime_credit.py @@ -0,0 +1,92 @@ +"""Tests for the overtime credits endpoint.""" + +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED) + +from timed.employment.factories import OvertimeCreditFactory +from timed.jsonapi_test_case import JSONAPITestCase + + +class OvertimeCreditTests(JSONAPITestCase): + """Tests for the overtime credits endpoint. + + This endpoint should be read only for normal users. + """ + + def setUp(self): + """Setup the environment for the tests.""" + super().setUp() + + self.overtime_credits = OvertimeCreditFactory.create_batch( + 5, + user=self.user + ) + + OvertimeCreditFactory.create_batch(5) + + def test_overtime_credit_list(self): + """Should respond with a list of overtime credits.""" + url = reverse('overtime-credit-list') + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == len(self.overtime_credits) + + def test_overtime_credit_detail(self): + """Should respond with a single overtime credit.""" + overtime_credit = self.overtime_credits[0] + + url = reverse('overtime-credit-detail', args=[ + overtime_credit.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_overtime_credit_create(self): + """Should not be able to create a new overtime credit.""" + url = reverse('overtime-credit-list') + + noauth_res = self.noauth_client.post(url) + res = self.client.post(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_overtime_credit_update(self): + """Should not be able to update an existing overtime credit.""" + overtime_credit = self.overtime_credits[0] + + url = reverse('overtime-credit-detail', args=[ + overtime_credit.id + ]) + + noauth_res = self.noauth_client.patch(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_overtime_credit_delete(self): + """Should not be able delete an overtime credit.""" + overtime_credit = self.overtime_credits[0] + + url = reverse('overtime-credit-detail', args=[ + overtime_credit.id + ]) + + noauth_res = self.noauth_client.delete(url) + res = self.client.patch(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 9e9928ef2..7bec853db 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -1,12 +1,18 @@ """Tests for the locations endpoint.""" +from datetime import date, timedelta + from django.core.urlresolvers import reverse +from django.utils.duration import duration_string from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND, HTTP_405_METHOD_NOT_ALLOWED) -from timed.employment.factories import UserFactory +from timed.employment.factories import (EmploymentFactory, + OvertimeCreditFactory, + PublicHolidayFactory, UserFactory) from timed.jsonapi_test_case import JSONAPITestCase +from timed.tracking.factories import ReportFactory class UserTests(JSONAPITestCase): @@ -21,6 +27,9 @@ def setUp(self): self.users = UserFactory.create_batch(3) + for user in self.users + [self.user]: + EmploymentFactory.create(user=user) + def test_user_list(self): """Should respond with a list of one user: the currently logged in.""" url = reverse('user-list') @@ -75,7 +84,7 @@ def test_user_create(self): def test_user_update(self): """Should not be able to update an existing user.""" - user = self.users[0] + user = self.user url = reverse('user-detail', args=[ user.id @@ -89,7 +98,7 @@ def test_user_update(self): def test_user_delete(self): """Should not be able delete a user.""" - user = self.users[0] + user = self.user url = reverse('user-detail', args=[ user.id @@ -100,3 +109,83 @@ def test_user_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + def test_user_worktime_balance(self): + """Should calculate correct worktime balances.""" + user = self.user + employment = user.employments.get(end_date__isnull=True) + + # Calculate over one week + start_date = date(2017, 3, 19) + end_date = date(2017, 3, 26) + + employment.start_date = start_date + employment.worktime_per_day = timedelta(hours=8) + + employment.save() + + # Overtime credit of 10 hours + OvertimeCreditFactory.create( + user=user, + date=start_date, + duration=timedelta(hours=10, minutes=30) + ) + + # One public holiday + PublicHolidayFactory.create( + date=start_date + timedelta(days=1), + location=employment.location + ) + + url = reverse('user-detail', args=[ + user.id + ]) + + res = self.client.get('{0}?until={1}'.format( + url, + end_date.strftime('%Y-%m-%d') + )) + + result = self.result(res) + + # 5 workdays minus one holiday minus 10 hours overtime credit + expected_worktime = ( + 4 * employment.worktime_per_day - + timedelta(hours=10, minutes=30) + ) + + assert ( + result['data']['attributes']['worktime-balance'] == + duration_string(timedelta() - expected_worktime) + ) + + # 3x 10 hour reported worktime + ReportFactory.create( + user=user, + date=start_date + timedelta(days=3), + duration=timedelta(hours=10) + ) + + ReportFactory.create( + user=user, + date=start_date + timedelta(days=4), + duration=timedelta(hours=10) + ) + + ReportFactory.create( + user=user, + date=start_date + timedelta(days=5), + duration=timedelta(hours=10) + ) + + res2 = self.client.get('{0}?until={1}'.format( + url, + end_date.strftime('%Y-%m-%d') + )) + + result2 = self.result(res2) + + assert ( + result2['data']['attributes']['worktime-balance'] == + duration_string(timedelta(hours=30) - expected_worktime) + ) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 7b330c332..4c9182457 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1,10 +1,15 @@ """Tests for the reports endpoint.""" +from datetime import timedelta + from django.contrib.auth.models import User from django.core.urlresolvers import reverse +from django.utils.duration import duration_string from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) + HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED) +from timed.employment.factories import AbsenceTypeFactory from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import TaskFactory from timed.tracking.factories import ReportFactory @@ -102,6 +107,8 @@ def test_report_update(self): """Should update an existing report.""" report = self.reports[0] + absence_type = AbsenceTypeFactory.create() + data = { 'data': { 'type': 'reports', @@ -114,6 +121,12 @@ def test_report_update(self): 'relationships': { 'task': { 'data': None + }, + 'absence-type': { + 'data': { + 'type': 'absence-types', + 'id': absence_type.id + } } } } @@ -161,3 +174,50 @@ def test_report_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert user_res.status_code == HTTP_204_NO_CONTENT + + def test_report_task_or_absence_type_required(self): + """Should not be able to save reports without task or absence type.""" + report = self.reports[0] + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': {}, + 'relationships': { + 'task': { + 'data': None + }, + 'absence-type': { + 'data': None + } + } + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + res = self.client.patch(url, data) + + assert res.status_code == HTTP_400_BAD_REQUEST + + def test_report_round_duration(self): + """Should round the duration of a report to 15 minutes.""" + report = self.reports[0] + + report.duration = timedelta(hours=1, minutes=7) + report.save() + + assert duration_string(report.duration) == '01:00:00' + + report.duration = timedelta(hours=1, minutes=8) + report.save() + + assert duration_string(report.duration) == '01:15:00' + + report.duration = timedelta(hours=1, minutes=53) + report.save() + + assert duration_string(report.duration) == '02:00:00' From 33573420afd2b7e329a582060f48eeb7a7afc5f5 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Mar 2017 14:05:32 +0100 Subject: [PATCH 058/980] A report must at least be 15 minutes long --- timed/tracking/models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 1ccc5c402..de5f16a1a 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -138,12 +138,16 @@ def save(self, *args, **kwargs): """Customized save method. This rounds the duration of the report to the nearest 15 minutes. + However, the duration must at least be 15 minutes long. :returns: The saved report :rtype: timed.tracking.models.Report """ self.duration = timedelta( - seconds=round(self.duration.seconds / (15 * 60)) * (15 * 60) + seconds=max( + 15 * 60, + round(self.duration.seconds / (15 * 60)) * (15 * 60) + ) ) return super().save(*args, **kwargs) From db6038c8b86e2b170ae3695acc41fff607b11dd0 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Mar 2017 14:05:53 +0100 Subject: [PATCH 059/980] Include absence credits with the user --- timed/employment/serializers.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index b124eeeaa..2499bb1e1 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -16,7 +16,8 @@ class UserSerializer(ModelSerializer): """User serializer.""" - employments = ResourceRelatedField(many=True, read_only=True) + employments = ResourceRelatedField(many=True, read_only=True) + absence_credits = ResourceRelatedField(many=True, read_only=True) worktime_balance = SerializerMethodField() def get_worktime_balance_raw(self, instance): @@ -103,7 +104,10 @@ def get_worktime_balance(self, instance): return duration_string(worktime_balance) included_serializers = { - 'employments': 'timed.employment.serializers.EmploymentSerializer' + 'employments': + 'timed.employment.serializers.EmploymentSerializer', + 'absence_credits': + 'timed.employment.serializers.AbsenceCreditSerializer' } class Meta: @@ -116,6 +120,7 @@ class Meta: 'last_name', 'email', 'employments', + 'absence_credits', 'worktime_balance', ] From bb8de131a72ec566fc5ff1cccec23964e7b8c561 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 24 Mar 2017 15:05:55 +0100 Subject: [PATCH 060/980] Make comment on report optional --- .../migrations/0003_auto_20170324_1505.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 timed/tracking/migrations/0003_auto_20170324_1505.py diff --git a/timed/tracking/migrations/0003_auto_20170324_1505.py b/timed/tracking/migrations/0003_auto_20170324_1505.py new file mode 100644 index 000000000..643ca0e6b --- /dev/null +++ b/timed/tracking/migrations/0003_auto_20170324_1505.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-03-24 14:05 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0002_auto_20170323_1031'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='comment', + field=models.CharField(blank=True, max_length=255), + ), + ] From b0a6adf8adb7b22ea1b8e752ee38d203a670bc71 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 27 Mar 2017 15:51:03 +0200 Subject: [PATCH 061/980] Added missing included serializer --- timed/tracking/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index f43655416..139a75fac 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -136,6 +136,7 @@ def validate(self, data): included_serializers = { 'task': 'timed.projects.serializers.TaskSerializer', 'user': 'timed.employment.serializers.UserSerializer', + 'absence_type': 'timed.employment.serializers.AbsenceTypeSerializer', } class Meta: From ea5aa7996fbaf5929a2d95c42115328e15b3c711 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 27 Mar 2017 16:02:03 +0200 Subject: [PATCH 062/980] Fixed bug where an active employment could not be edited --- timed/employment/admin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index d784e5b89..36deee289 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -27,6 +27,9 @@ def clean(self): employments = models.Employment.objects.filter(user=data.get('user')) + if self.instance: + employments = employments.exclude(id=self.instance.id) + if ( data.get('end_date') and data.get('start_date') >= data.get('end_date') From 25956e2be1155babcf4c111d137a4a86d1d0658e Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 30 Mar 2017 17:46:22 +0200 Subject: [PATCH 063/980] Verbode testing --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e4d39f546..9f61aa4e2 100644 --- a/Makefile +++ b/Makefile @@ -31,4 +31,4 @@ docs: test: ## Test the project @flake8 - @pytest --cov --create-db + @pytest -v --cov --create-db From 953aedcd549260882319e395aab1d5379080eb0b Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 30 Mar 2017 17:46:57 +0200 Subject: [PATCH 064/980] Remove verbose testing --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9f61aa4e2..e4d39f546 100644 --- a/Makefile +++ b/Makefile @@ -31,4 +31,4 @@ docs: test: ## Test the project @flake8 - @pytest -v --cov --create-db + @pytest --cov --create-db From ffa96ca39e0c282fe56e28a169ec19f2c93093b4 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 1 May 2017 15:53:37 +0200 Subject: [PATCH 065/980] Fixed issue where the generated report got deleted when the activity was deleted --- .../migrations/0004_auto_20170501_1551.py | 21 +++++++++++++++++++ timed/tracking/models.py | 1 + 2 files changed, 22 insertions(+) create mode 100644 timed/tracking/migrations/0004_auto_20170501_1551.py diff --git a/timed/tracking/migrations/0004_auto_20170501_1551.py b/timed/tracking/migrations/0004_auto_20170501_1551.py new file mode 100644 index 000000000..502fbf49d --- /dev/null +++ b/timed/tracking/migrations/0004_auto_20170501_1551.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-05-01 13:51 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0003_auto_20170324_1505'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='tracking.Activity'), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index de5f16a1a..803c2b2ab 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -126,6 +126,7 @@ class Report(models.Model): activity = models.ForeignKey(Activity, null=True, blank=True, + on_delete=models.SET_NULL, related_name='reports') absence_type = models.ForeignKey('employment.AbsenceType', null=True, From b3fe77e7832469682722b6509ab945d3dfd3230c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 1 May 2017 16:12:08 +0200 Subject: [PATCH 066/980] Fixed flake8 errors --- timed/employment/models.py | 8 ++++---- timed/employment/serializers.py | 10 +++++----- timed/employment/tests/test_absence_credit.py | 2 +- timed/employment/tests/test_absence_type.py | 2 +- timed/employment/tests/test_employment.py | 2 +- timed/employment/tests/test_location.py | 2 +- timed/employment/tests/test_overtime_credit.py | 2 +- timed/employment/tests/test_public_holiday.py | 2 +- timed/employment/tests/test_user.py | 2 +- timed/jsonapi_test_case.py | 2 +- timed/projects/models.py | 8 ++++---- timed/projects/tests/test_customer.py | 2 +- timed/projects/tests/test_project.py | 2 +- timed/projects/tests/test_task.py | 2 +- timed/tracking/factories.py | 4 ++-- timed/tracking/filters.py | 4 ++-- timed/tracking/models.py | 14 +++++++------- timed/tracking/tests/test_activity.py | 2 +- timed/tracking/tests/test_activity_block.py | 2 +- timed/tracking/tests/test_attendance.py | 2 +- timed/tracking/tests/test_report.py | 2 +- 21 files changed, 39 insertions(+), 39 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index be9a2a1c1..69bfc8be0 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -14,7 +14,7 @@ class Location(models.Model): name = models.CharField(max_length=50) def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -40,7 +40,7 @@ class Employment(models.Model): end_date = models.DateField(blank=True, null=True) def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -65,7 +65,7 @@ class PublicHoliday(models.Model): related_name='public_holidays') def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -83,7 +83,7 @@ class AbsenceType(models.Model): name = models.CharField(max_length=50) def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 2499bb1e1..5883bcdd0 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -94,7 +94,7 @@ def get_worktime_balance_raw(self, instance): return reported_worktime + overtime_credit - expected_worktime def get_worktime_balance(self, instance): - """The formatted worktime balance. + """Format the worktime balance. :return: The formatted worktime balance. :rtype: str @@ -199,7 +199,7 @@ class AbsenceCreditSerializer(ModelSerializer): balance = SerializerMethodField() def get_used_raw(self, instance): - """The total of used time since the date of the requested credit. + """Calculate the total of used time since the date of the requested credit. This is the sum of all durations of reports, which are assigned to the credits user, absence type and were created at or after the date of @@ -227,7 +227,7 @@ def get_used_raw(self, instance): return sum(reports, timedelta()) def get_balance_raw(self, instance): - """The balance of the requested credit. + """Calculate the balance of the requested credit. This is the difference between the credits duration and the total used time. @@ -242,7 +242,7 @@ def get_balance_raw(self, instance): ) def get_used(self, instance): - """The formatted total of used time. + """Format the total of used time. :return: The formatted total of used time :rtype: str @@ -252,7 +252,7 @@ def get_used(self, instance): return duration_string(used) def get_balance(self, instance): - """The formatted balance. + """Format the balance. This is None if we don't have a duration. diff --git a/timed/employment/tests/test_absence_credit.py b/timed/employment/tests/test_absence_credit.py index 42b78fef9..e0e672729 100644 --- a/timed/employment/tests/test_absence_credit.py +++ b/timed/employment/tests/test_absence_credit.py @@ -18,7 +18,7 @@ class AbsenceCreditTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.absence_credits = AbsenceCreditFactory.create_batch( diff --git a/timed/employment/tests/test_absence_type.py b/timed/employment/tests/test_absence_type.py index 62bbd56b6..7247d0303 100644 --- a/timed/employment/tests/test_absence_type.py +++ b/timed/employment/tests/test_absence_type.py @@ -15,7 +15,7 @@ class AbsenceTypeTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.absence_types = AbsenceTypeFactory.create_batch(5) diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 1cda29ba1..736c8e484 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -19,7 +19,7 @@ class EmploymentTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.employments = [ diff --git a/timed/employment/tests/test_location.py b/timed/employment/tests/test_location.py index aa7e27b0e..2e7f895a6 100644 --- a/timed/employment/tests/test_location.py +++ b/timed/employment/tests/test_location.py @@ -15,7 +15,7 @@ class LocationTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.locations = LocationFactory.create_batch(3) diff --git a/timed/employment/tests/test_overtime_credit.py b/timed/employment/tests/test_overtime_credit.py index aaeff250f..149959f7a 100644 --- a/timed/employment/tests/test_overtime_credit.py +++ b/timed/employment/tests/test_overtime_credit.py @@ -15,7 +15,7 @@ class OvertimeCreditTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.overtime_credits = OvertimeCreditFactory.create_batch( diff --git a/timed/employment/tests/test_public_holiday.py b/timed/employment/tests/test_public_holiday.py index b0d20a247..7bc62509c 100644 --- a/timed/employment/tests/test_public_holiday.py +++ b/timed/employment/tests/test_public_holiday.py @@ -16,7 +16,7 @@ class PublicHolidayTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.public_holidays = PublicHolidayFactory.create_batch(10) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 7bec853db..05259f2d9 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -22,7 +22,7 @@ class UserTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.users = UserFactory.create_batch(3) diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 830e17439..8bdbb0df5 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -106,7 +106,7 @@ class JSONAPITestCase(APITestCase): """Base test case for testing the timed API.""" def setUp(self): - """Setup the clients for testing.""" + """Set the clients for testing up.""" super().setUp() self.user = User.objects.create_user( diff --git a/timed/projects/models.py b/timed/projects/models.py index bc20fc4da..8c4c24376 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -19,7 +19,7 @@ class Customer(models.Model): archived = models.BooleanField(default=False) def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -47,7 +47,7 @@ class Project(models.Model): related_name='projects') def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -69,7 +69,7 @@ class Task(models.Model): related_name='tasks') def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -87,7 +87,7 @@ class TaskTemplate(models.Model): name = models.CharField(max_length=255) def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index a293706fe..ccb89b8c5 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -15,7 +15,7 @@ class CustomerTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.customers = CustomerFactory.create_batch(10) diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 68aeca770..d2d504128 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -16,7 +16,7 @@ class ProjectTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.projects = ProjectFactory.create_batch(10) diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 558d9da86..36a81eb4f 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -15,7 +15,7 @@ class TaskTests(JSONAPITestCase): """ def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() self.tasks = TaskFactory.create_batch(5) diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index acf139ba7..138b6459a 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -16,7 +16,7 @@ def begin_of_day(day): - """Function for determining the start of a day. + """Determine the start of a day. :param datetime.datetime day: The datetime to get the day from :return: The start of the day @@ -32,7 +32,7 @@ def begin_of_day(day): def end_of_day(day): - """Function for determining the end of a day. + """Determine the end of a day. :param datetime.datetime day: The datetime to get the day from :return: The end of the day diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 20764ca26..0672ea887 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -9,7 +9,7 @@ def boolean_filter(func): - """Decorator for casting the passed query parameter into a boolean. + """Cast the passed query parameter into a boolean. :param function func: The function to decorate :return: The function called with a boolean @@ -17,7 +17,7 @@ def boolean_filter(func): """ @wraps(func) def wrapper(self, qs, value): - """The wrapper. + """Wrap the initial function. :param QuerySet qs: The queryset to filter :param str value: The value to cast diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 803c2b2ab..7be019a40 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -22,7 +22,7 @@ class Activity(models.Model): @property def duration(self): - """The total duration of this activity. + """Calculate the total duration of this activity. :return: The total duration :rtype: datetime.timedelta @@ -37,7 +37,7 @@ def duration(self): return sum(durations, timedelta()) def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -63,7 +63,7 @@ class ActivityBlock(models.Model): @property def duration(self): - """The duration of this activity block. + """Calculate the duration of this activity block. :return: The duration :rtype: datetime.timedelta or None @@ -74,7 +74,7 @@ def duration(self): return self.to_datetime - self.from_datetime def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -94,7 +94,7 @@ class Attendance(models.Model): related_name='attendances') def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str @@ -136,7 +136,7 @@ class Report(models.Model): related_name='reports') def save(self, *args, **kwargs): - """Customized save method. + """Save the report with some custom functionality. This rounds the duration of the report to the nearest 15 minutes. However, the duration must at least be 15 minutes long. @@ -154,7 +154,7 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) def __str__(self): - """String representation. + """Represent the model as a string. :return: The string representation :rtype: str diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 412972b03..639825d2b 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -16,7 +16,7 @@ class ActivityTests(JSONAPITestCase): """Tests for the activities endpoint.""" def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() other_user = User.objects.create_user( diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py index 3fcc72689..c0d399b58 100644 --- a/timed/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -17,7 +17,7 @@ class ActivityBlockTests(JSONAPITestCase): """Tests for the activity blocks endpoint.""" def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() other_user = User.objects.create_user( diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index cf44bf79f..551edcb8e 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -15,7 +15,7 @@ class AttendanceTests(JSONAPITestCase): """Tests for the attendances endpoint.""" def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() other_user = User.objects.create_user( diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 4c9182457..f38851377 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -19,7 +19,7 @@ class ReportTests(JSONAPITestCase): """Tests for the reports endpoint.""" def setUp(self): - """Setup the environment for the tests.""" + """Set the environment for the tests up.""" super().setUp() other_user = User.objects.create_user( From 85c87df8dfe3390a45c8fc18eb4f84324411305e Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 1 May 2017 16:31:40 +0200 Subject: [PATCH 067/980] Fixed alignment of a property --- timed/employment/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 5883bcdd0..616f9f83b 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -196,7 +196,7 @@ class AbsenceCreditSerializer(ModelSerializer): absence_type = ResourceRelatedField(read_only=True) user = ResourceRelatedField(read_only=True) used = SerializerMethodField() - balance = SerializerMethodField() + balance = SerializerMethodField() def get_used_raw(self, instance): """Calculate the total of used time since the date of the requested credit. From 92bdf5c32e9a6d2fd293e61407f06783c7446878 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 1 May 2017 16:34:00 +0200 Subject: [PATCH 068/980] Clarified the docstring for the absence credit model --- timed/employment/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 69bfc8be0..ffb8b4318 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -94,8 +94,9 @@ def __str__(self): class AbsenceCredit(models.Model): """Absence credit model. - An absence credit is a credit for an absence of a certain type. An absence - is a report marked as an absence by referencing an absence type. + An absence credit is a credit for an absence of a certain type. A user + should only be able to create as many absences as defined in this credit. + E.g a credit that defines that a user can only have 25 holidays. """ user = models.ForeignKey(settings.AUTH_USER_MODEL, From ed58738d543b8cc3b9bddb760e710011a14f6d93 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 4 May 2017 13:01:33 +0200 Subject: [PATCH 069/980] Made the task on an activity optional --- .../migrations/0005_auto_20170504_1259.py | 21 ++++++++++++++ timed/tracking/models.py | 2 ++ timed/tracking/serializers.py | 4 ++- timed/tracking/tests/test_activity.py | 29 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 timed/tracking/migrations/0005_auto_20170504_1259.py diff --git a/timed/tracking/migrations/0005_auto_20170504_1259.py b/timed/tracking/migrations/0005_auto_20170504_1259.py new file mode 100644 index 000000000..3637e833a --- /dev/null +++ b/timed/tracking/migrations/0005_auto_20170504_1259.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-05-04 10:59 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0004_auto_20170501_1551'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='projects.Task'), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 7be019a40..38bbe6746 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -16,6 +16,8 @@ class Activity(models.Model): comment = models.CharField(max_length=255, blank=True) start_datetime = models.DateTimeField(auto_now_add=True) task = models.ForeignKey('projects.Task', + null=True, + blank=True, related_name='activities') user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='activities') diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 139a75fac..f847d523f 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -17,7 +17,9 @@ class ActivitySerializer(ModelSerializer): duration = DurationField(read_only=True) user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) - task = ResourceRelatedField(queryset=Task.objects.all()) + task = ResourceRelatedField(queryset=Task.objects.all(), + allow_null=True, + required=False) blocks = ResourceRelatedField(read_only=True, many=True) included_serializers = { diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 639825d2b..b71eefc78 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -189,3 +189,32 @@ def test_activity_list_filter_day(self): for data in result['data'] ]) + + def test_activity_create_no_task(self): + """Should create a new activity without a task.""" + data = { + 'data': { + 'type': 'activities', + 'id': None, + 'attributes': { + 'comment': 'Test activity' + }, + 'relationships': { + 'task': { + 'data': None + } + } + } + } + + url = reverse('activity-list') + + noauth_res = self.noauth_client.post(url, data) + res = self.client.post(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_201_CREATED + + result = self.result(res) + + assert result['data']['relationships']['task']['data'] is None From 1b27b4b4c61c289edc60e70aa9a98c48aba3adf4 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 5 May 2017 15:55:21 +0200 Subject: [PATCH 070/980] Added some filters for the weekly overview --- timed/tracking/filters.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 0672ea887..0fe9f0a77 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -3,7 +3,7 @@ import datetime from functools import wraps -from django_filters import Filter, FilterSet +from django_filters import DateFilter, Filter, FilterSet from timed.tracking import models @@ -109,6 +109,9 @@ class Meta: class ReportFilterSet(FilterSet): """Filter set for the reports endpoint.""" + from_date = DateFilter(name='date', lookup_expr='gte') + to_date = DateFilter(name='date', lookup_expr='lte') + class Meta: """Meta information for the report filter set.""" From 3e469840e1e3b7588a2f22fc7698968f4d00884e Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 5 May 2017 16:34:41 +0200 Subject: [PATCH 071/980] Added the new filters to meta fields --- timed/tracking/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 0fe9f0a77..f77270c6d 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -116,4 +116,4 @@ class Meta: """Meta information for the report filter set.""" model = models.Report - fields = ['date'] + fields = ['date', 'from_date', 'to_date'] From db78ad0da1b8f16223a9409d89945a076d6d5c20 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 8 May 2017 11:15:39 +0200 Subject: [PATCH 072/980] properly configure what packages are exposed --- .gitignore | 3 +++ Makefile | 3 +-- setup.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 219b8fa50..3391b7257 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ target/ # Pyenv .python-version + +# Editor swap files +*.swp diff --git a/Makefile b/Makefile index e4d39f546..8d46008c7 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,7 @@ install: ## Install production environment @pip install --upgrade . install-dev: ## Install development environment - @pip install --upgrade -r requirements.txt - @pip install --upgrade -r dev_requirements.txt + @pip install --upgrade -r requirements.txt -r dev_requirements.txt @pip install -e . setup-ldap: ## Setup the LDAP container diff --git a/setup.py b/setup.py index fff53dd52..09c7aa6da 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import codecs -from setuptools import setup +from setuptools import find_packages, setup from timed import __version__ @@ -18,6 +18,7 @@ long_description=README_TEXT, keywords='timetracking', url='https://adfinis-sygroup.ch/', + packages=find_packages(), classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', From 3c421b505ac960ff562775cb440f00bc799ebf82 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 8 May 2017 17:35:29 +0200 Subject: [PATCH 073/980] Added own model for absences --- timed/employment/factories.py | 3 +- .../0003_absencetype_fill_worktime.py | 20 ++ timed/employment/models.py | 25 +- timed/employment/serializers.py | 24 +- timed/employment/tests/test_absence_credit.py | 71 +++-- timed/employment/tests/test_employment.py | 24 ++ timed/employment/tests/test_user.py | 11 +- timed/tracking/admin.py | 9 +- timed/tracking/factories.py | 24 +- timed/tracking/filters.py | 10 + .../migrations/0006_auto_20170508_1045.py | 68 +++++ timed/tracking/models.py | 68 ++++- timed/tracking/serializers.py | 75 +++-- timed/tracking/tests/test_absence.py | 273 ++++++++++++++++++ timed/tracking/tests/test_report.py | 74 +++-- timed/tracking/urls.py | 1 + timed/tracking/views.py | 15 + 17 files changed, 678 insertions(+), 117 deletions(-) create mode 100644 timed/employment/migrations/0003_absencetype_fill_worktime.py create mode 100644 timed/tracking/migrations/0006_auto_20170508_1045.py create mode 100644 timed/tracking/tests/test_absence.py diff --git a/timed/employment/factories.py b/timed/employment/factories.py index e4604d492..67104051d 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -87,7 +87,8 @@ class Meta: class AbsenceTypeFactory(DjangoModelFactory): """Absence type factory.""" - name = Faker('word') + name = Faker('word') + fill_worktime = False class Meta: """Meta informations for the absence type factory.""" diff --git a/timed/employment/migrations/0003_absencetype_fill_worktime.py b/timed/employment/migrations/0003_absencetype_fill_worktime.py new file mode 100644 index 000000000..c17ee582c --- /dev/null +++ b/timed/employment/migrations/0003_absencetype_fill_worktime.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-05-08 08:45 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0002_auto_20170323_1031'), + ] + + operations = [ + migrations.AddField( + model_name='absencetype', + name='fill_worktime', + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index ffb8b4318..02b35d4e7 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -39,6 +39,28 @@ class Employment(models.Model): start_date = models.DateField() end_date = models.DateField(blank=True, null=True) + @classmethod + def employment_at(cls, user, date): + """Get the employment on a date for a user. + + :returns: The employment on the date for the user + :rtype: timed.employment.models.Employment + """ + try: + return cls.objects.get( + ( + models.Q(end_date__gte=date) | + models.Q(end_date__isnull=True) + ), + start_date__lte=date, + user=user + ) + except Exception: + raise Exception('User {0} had no employment on {1}'.format( + user.username, + date.strftime('%Y-%m-%d') + )) + def __str__(self): """Represent the model as a string. @@ -80,7 +102,8 @@ class AbsenceType(models.Model): school. """ - name = models.CharField(max_length=50) + name = models.CharField(max_length=50) + fill_worktime = models.BooleanField(default=False) def __str__(self): """Represent the model as a string. diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 616f9f83b..595791e8b 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -10,7 +10,7 @@ SerializerMethodField) from timed.employment import models -from timed.tracking.models import Report +from timed.tracking.models import Absence, Report class UserSerializer(ModelSerializer): @@ -91,7 +91,21 @@ def get_worktime_balance_raw(self, instance): timedelta() ) - return reported_worktime + overtime_credit - expected_worktime + absences = sum( + Absence.objects.filter( + user=instance, + date__gte=start_date, + date__lte=end_date + ).values_list('duration', flat=True), + timedelta() + ) + + return ( + reported_worktime + + absences + + overtime_credit - + expected_worktime + ) def get_worktime_balance(self, instance): """Format the worktime balance. @@ -187,7 +201,7 @@ class Meta: """Meta information for the absence type serializer.""" model = models.AbsenceType - fields = ['name'] + fields = ['name', 'fill_worktime'] class AbsenceCreditSerializer(ModelSerializer): @@ -217,9 +231,9 @@ def get_used_raw(self, instance): else date.today() ) - reports = Report.objects.filter( + reports = Absence.objects.filter( user=instance.user, - absence_type=instance.absence_type, + type=instance.absence_type, date__gte=instance.date, date__lte=end_date ).values_list('duration', flat=True) diff --git a/timed/employment/tests/test_absence_credit.py b/timed/employment/tests/test_absence_credit.py index e0e672729..f69d78a1c 100644 --- a/timed/employment/tests/test_absence_credit.py +++ b/timed/employment/tests/test_absence_credit.py @@ -6,9 +6,9 @@ from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) -from timed.employment.factories import AbsenceCreditFactory +from timed.employment.factories import AbsenceCreditFactory, EmploymentFactory from timed.jsonapi_test_case import JSONAPITestCase -from timed.tracking.factories import ReportFactory +from timed.tracking.factories import AbsenceFactory class AbsenceCreditTests(JSONAPITestCase): @@ -101,17 +101,21 @@ def test_absence_credit_balance(self): duration=timedelta(hours=30) ) - ReportFactory.create_batch( - 2, + EmploymentFactory.create( user=self.user, - absence_type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=1), - duration=timedelta(hours=8) + start_date=absence_credit.date - timedelta(days=1), + worktime_per_day=timedelta(hours=8) ) - ReportFactory.create( + AbsenceFactory.create( user=self.user, - absence_type=absence_credit.absence_type, + type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=1) + ) + + AbsenceFactory.create( + user=self.user, + type=absence_credit.absence_type, date=absence_credit.date - timedelta(days=1), duration=timedelta(hours=8) ) @@ -124,8 +128,8 @@ def test_absence_credit_balance(self): result = self.result(res) assert result['data']['attributes']['duration'] == '1 06:00:00' - assert result['data']['attributes']['used'] == '16:00:00' - assert result['data']['attributes']['balance'] == '14:00:00' + assert result['data']['attributes']['used'] == '08:00:00' + assert result['data']['attributes']['balance'] == '22:00:00' def test_absence_credit_balance_no_duration(self): """Should not calculate an absence credit balance.""" @@ -134,11 +138,16 @@ def test_absence_credit_balance_no_duration(self): duration=None ) - ReportFactory.create( + EmploymentFactory.create( user=self.user, - absence_type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=1), - duration=timedelta(hours=8) + start_date=absence_credit.date, + worktime_per_day=timedelta(hours=8) + ) + + AbsenceFactory.create( + user=self.user, + type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=1) ) url = reverse('absence-credit-detail', args=[ @@ -159,26 +168,28 @@ def test_absence_credit_balance_until(self): duration=timedelta(hours=30) ) - ReportFactory.create_batch( - 2, + EmploymentFactory.create( user=self.user, - absence_type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=1), - duration=timedelta(hours=8) + start_date=absence_credit.date - timedelta(days=1), + worktime_per_day=timedelta(hours=8) ) - ReportFactory.create( + AbsenceFactory.create( user=self.user, - absence_type=absence_credit.absence_type, - date=absence_credit.date - timedelta(days=1), - duration=timedelta(hours=8) + type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=1) ) - ReportFactory.create( + AbsenceFactory.create( user=self.user, - absence_type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=2), - duration=timedelta(hours=8) + type=absence_credit.absence_type, + date=absence_credit.date - timedelta(days=1) + ) + + AbsenceFactory.create( + user=self.user, + type=absence_credit.absence_type, + date=absence_credit.date + timedelta(days=2) ) url = reverse('absence-credit-detail', args=[ @@ -193,5 +204,5 @@ def test_absence_credit_balance_until(self): result = self.result(res) assert result['data']['attributes']['duration'] == '1 06:00:00' - assert result['data']['attributes']['used'] == '16:00:00' - assert result['data']['attributes']['balance'] == '14:00:00' + assert result['data']['attributes']['used'] == '08:00:00' + assert result['data']['attributes']['balance'] == '22:00:00' diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 736c8e484..f56a3d7e2 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -9,6 +9,7 @@ from timed.employment.admin import EmploymentForm from timed.employment.factories import EmploymentFactory +from timed.employment.models import Employment from timed.jsonapi_test_case import JSONAPITestCase @@ -116,3 +117,26 @@ def test_employment_unique_range(self): with pytest.raises(ValueError): form.save() + + def test_employment_at(self): + """Should return the right employment on a date.""" + employment = Employment.objects.get(user=self.user, + end_date__isnull=True) + + assert ( + Employment.employment_at(self.user, employment.start_date) == + employment + ) + + employment.end_date = ( + employment.start_date + + datetime.timedelta(days=20) + ) + + employment.save() + + with pytest.raises(Exception): + Employment.employment_at( + self.user, + employment.start_date + datetime.timedelta(days=21) + ) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 05259f2d9..0798f21c3 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -12,7 +12,7 @@ OvertimeCreditFactory, PublicHolidayFactory, UserFactory) from timed.jsonapi_test_case import JSONAPITestCase -from timed.tracking.factories import ReportFactory +from timed.tracking.factories import AbsenceFactory, ReportFactory class UserTests(JSONAPITestCase): @@ -159,7 +159,7 @@ def test_user_worktime_balance(self): duration_string(timedelta() - expected_worktime) ) - # 3x 10 hour reported worktime + # 2x 10 hour reported worktime ReportFactory.create( user=user, date=start_date + timedelta(days=3), @@ -172,10 +172,9 @@ def test_user_worktime_balance(self): duration=timedelta(hours=10) ) - ReportFactory.create( + AbsenceFactory.create( user=user, - date=start_date + timedelta(days=5), - duration=timedelta(hours=10) + date=start_date + timedelta(days=5) ) res2 = self.client.get('{0}?until={1}'.format( @@ -187,5 +186,5 @@ def test_user_worktime_balance(self): assert ( result2['data']['attributes']['worktime-balance'] == - duration_string(timedelta(hours=30) - expected_worktime) + duration_string(timedelta(hours=28) - expected_worktime) ) diff --git a/timed/tracking/admin.py b/timed/tracking/admin.py index d28bc2487..f889a2d88 100644 --- a/timed/tracking/admin.py +++ b/timed/tracking/admin.py @@ -53,4 +53,11 @@ class AttendanceAdmin(OwnerAdminMixin, admin.ModelAdmin): class ReportAdmin(OwnerAdminMixin, admin.ModelAdmin): """Report admin view.""" - list_display = ['user', 'task', 'duration', 'comment'] + list_display = ['user', 'task', 'date', 'duration', 'comment'] + + +@admin.register(models.Absence) +class AbsenceAdmin(OwnerAdminMixin, admin.ModelAdmin): + """Absence admin view.""" + + list_display = ['user', 'date', 'duration', 'type'] diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 138b6459a..32fde5062 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -8,6 +8,7 @@ from faker import Factory as FakerFactory from pytz import timezone +from timed.employment.models import Employment from timed.tracking import models tzinfo = timezone('Europe/Zurich') @@ -85,7 +86,6 @@ class ReportFactory(DjangoModelFactory): date = Faker('date') review = False not_billable = False - absence_type = None task = SubFactory('timed.projects.factories.TaskFactory') user = SubFactory('timed.employment.factories.UserFactory') @@ -141,3 +141,25 @@ class Meta: """Meta informations for the activity block factory.""" model = models.ActivityBlock + + +class AbsenceFactory(DjangoModelFactory): + """Absence factory.""" + + user = SubFactory('timed.employment.factories.UserFactory') + type = SubFactory('timed.employment.factories.AbsenceTypeFactory') + date = Faker('date') + + @lazy_attribute + def duration(self): + """Take the users employment worktime per day as duration. + + :return: The computed duration + :rtype: datetime.timedelta + """ + return Employment.employment_at(self.user, self.date).worktime_per_day + + class Meta: + """Meta informations for the absence factory.""" + + model = models.Absence diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index f77270c6d..e1fe279ef 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -117,3 +117,13 @@ class Meta: model = models.Report fields = ['date', 'from_date', 'to_date'] + + +class AbsenceFilterSet(FilterSet): + """Filter set for the absences endpoint.""" + + class Meta: + """Meta information for the absence filter set.""" + + model = models.Absence + fields = ['date'] diff --git a/timed/tracking/migrations/0006_auto_20170508_1045.py b/timed/tracking/migrations/0006_auto_20170508_1045.py new file mode 100644 index 000000000..a82583a74 --- /dev/null +++ b/timed/tracking/migrations/0006_auto_20170508_1045.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-05-08 08:45 +from __future__ import unicode_literals + +from datetime import timedelta +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +def migrate_absences_forwards(apps, schema_editor): + Report = apps.get_model('tracking', 'Report') + Absence = apps.get_model('tracking', 'Absence') + + for report in Report.objects.filter(absence_type__isnull=False): + Absence.objects.get_or_create( + user=report.user, + date=report.date, + defaults={ + 'type': report.absence_type, + 'duration': report.duration + } + ) + +def migrate_absences_reverse(apps, schema_editor): + Report = apps.get_model('tracking', 'Report') + Absence = apps.get_model('tracking', 'Absence') + + for absence in Absence.objects.all(): + Report.objects.create( + user=absence.user, + date=absence.date, + type=absence.absence_type, + duration=absence.duration + ) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('employment', '0003_absencetype_fill_worktime'), + ('tracking', '0005_auto_20170504_1259'), + ] + + operations = [ + migrations.CreateModel( + name='Absence', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('duration', models.DurationField(default=timedelta())), + ('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='employment.AbsenceType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='absence', + unique_together=set([('date', 'user')]), + ), + migrations.RunPython( + migrate_absences_forwards, + migrate_absences_reverse + ), + migrations.RemoveField( + model_name='report', + name='absence_type', + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 38bbe6746..b09c01b33 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -5,6 +5,8 @@ from django.conf import settings from django.db import models +from timed.employment.models import Employment + class Activity(models.Model): """Activity model. @@ -130,10 +132,6 @@ class Report(models.Model): blank=True, on_delete=models.SET_NULL, related_name='reports') - absence_type = models.ForeignKey('employment.AbsenceType', - null=True, - blank=True, - related_name='reports') user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='reports') @@ -142,9 +140,9 @@ def save(self, *args, **kwargs): This rounds the duration of the report to the nearest 15 minutes. However, the duration must at least be 15 minutes long. - - :returns: The saved report - :rtype: timed.tracking.models.Report + It also checks if an absence which should fill the expected worktime + exists on this date. If so, the duration of the absence needs to be + updated, by saving the absence again. """ self.duration = timedelta( seconds=max( @@ -153,7 +151,14 @@ def save(self, *args, **kwargs): ) ) - return super().save(*args, **kwargs) + super().save(*args, **kwargs) + + for absence in Absence.objects.filter( + user=self.user, + date=self.date, + type__fill_worktime=True + ): + absence.save() def __str__(self): """Represent the model as a string. @@ -162,3 +167,50 @@ def __str__(self): :rtype: str """ return '{0}: {1}'.format(self.user, self.task) + + +class Absence(models.Model): + """Absence model. + + An absence is time an employee was not working but still counts as + worktime. E.g holidays or sickness. + """ + + date = models.DateField() + duration = models.DurationField(default=timedelta()) + type = models.ForeignKey('employment.AbsenceType', + related_name='absences') + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='absences') + + def save(self, *args, **kwargs): + """Compute the duration of the absence and save it. + + The duration of an absence should be the worktime per day of the + employment. Unless an absence type should only fill the worktime (e.g + sickness), in which case the duration of the absence needs to fill the + difference between the reported time and the worktime per day. + """ + employment = Employment.employment_at(self.user, self.date) + + if self.type.fill_worktime: + worktime = sum( + Report.objects.filter( + date=self.date + ).values_list('duration', flat=True), + timedelta() + ) + + self.duration = max( + timedelta(), + employment.worktime_per_day - worktime + ) + else: + self.duration = employment.worktime_per_day + + super().save(*args, **kwargs) + + class Meta: + """Meta informations for the absence model.""" + + unique_together = ('date', 'user',) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index f847d523f..f1e30ad2d 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -6,7 +6,7 @@ ModelSerializer, ValidationError) -from timed.employment.models import AbsenceType +from timed.employment.models import AbsenceType, Employment, PublicHoliday from timed.projects.models import Task from timed.tracking import models @@ -109,36 +109,16 @@ class Meta: class ReportSerializer(ModelSerializer): """Report serializer.""" - task = ResourceRelatedField(queryset=Task.objects.all(), - allow_null=True, - required=False) - absence_type = ResourceRelatedField(queryset=AbsenceType.objects.all(), - allow_null=True, - required=False) + task = ResourceRelatedField(queryset=Task.objects.all()) activity = ResourceRelatedField(queryset=models.Activity.objects.all(), allow_null=True, required=False) user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) - def validate(self, data): - """Validate the report. - - Check if the report has either a task or an absence type. - - :return: The validated data - :rtype: dict - """ - if not data.get('task') and not data.get('absence_type'): - raise ValidationError('Either a task or a absence type ' - 'must be referenced.') - - return data - included_serializers = { 'task': 'timed.projects.serializers.TaskSerializer', - 'user': 'timed.employment.serializers.UserSerializer', - 'absence_type': 'timed.employment.serializers.AbsenceTypeSerializer', + 'user': 'timed.employment.serializers.UserSerializer' } class Meta: @@ -152,7 +132,54 @@ class Meta: 'review', 'not_billable', 'task', - 'absence_type', 'activity', 'user', ] + + +class AbsenceSerializer(ModelSerializer): + """Absence serializer.""" + + duration = DurationField(read_only=True) + type = ResourceRelatedField(queryset=AbsenceType.objects.all()) + user = ResourceRelatedField(read_only=True, + default=CurrentUserDefault()) + + included_serializers = { + 'user': 'timed.employment.serializers.UserSerializer', + } + + def validate(self, data): + """Validate the absence data. + + An absence should not be created on a public holiday or a weekend. + + :returns: The validated data + :rtype: dict + """ + if PublicHoliday.objects.filter( + location=Employment.employment_at( + data.get('user'), + data.get('date') + ).location, + date=data.get('date') + ).exists(): + raise ValidationError( + 'You can\'t create an absence on a public holiday' + ) + + if data.get('date').weekday() not in [1, 2, 3, 4, 5]: + raise ValidationError('You can\'t create an absence on a weekend') + + return data + + class Meta: + """Meta information for the absence serializer.""" + + model = models.Absence + fields = [ + 'date', + 'duration', + 'type', + 'user', + ] diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py new file mode 100644 index 000000000..f11bc1e4b --- /dev/null +++ b/timed/tracking/tests/test_absence.py @@ -0,0 +1,273 @@ +"""Tests for the attendances endpoint.""" + +import datetime + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED) + +from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, + PublicHolidayFactory) +from timed.employment.models import Employment +from timed.jsonapi_test_case import JSONAPITestCase +from timed.tracking.factories import AbsenceFactory, ReportFactory + + +class AbsenceTests(JSONAPITestCase): + """Tests for the absences endpoint.""" + + def setUp(self): + """Set the environment for the tests up.""" + super().setUp() + + user = self.user + + other_user = User.objects.create_user( + username='test', + password='123qweasd' + ) + + EmploymentFactory.create( + user=user, + start_date=datetime.date(2017, 5, 1), + end_date=None + ) + + EmploymentFactory.create( + user=other_user, + start_date=datetime.date(2017, 5, 1), + end_date=None + ) + + self.absences = [ + AbsenceFactory.create(user=user, date=datetime.date(2017, 5, 1)), + AbsenceFactory.create(user=user, date=datetime.date(2017, 5, 2)), + AbsenceFactory.create(user=user, date=datetime.date(2017, 5, 3)) + ] + + AbsenceFactory.create(user=other_user, date=datetime.date(2017, 5, 1)) + AbsenceFactory.create(user=other_user, date=datetime.date(2017, 5, 2)) + AbsenceFactory.create(user=other_user, date=datetime.date(2017, 5, 3)) + + def test_absence_list(self): + """Should respond with a list of absences filtered by user.""" + url = reverse('absence-list') + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == len(self.absences) + + def test_absence_detail(self): + """Should respond with a single absence.""" + absence = self.absences[0] + + url = reverse('absence-detail', args=[ + absence.id + ]) + + noauth_res = self.noauth_client.get(url) + res = self.client.get(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + def test_absence_create(self): + """Should create a new absence.""" + type = AbsenceTypeFactory.create() + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': datetime.date(2017, 5, 4).strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id + } + } + } + } + } + + url = reverse('absence-list') + + noauth_res = self.noauth_client.post(url, data) + res = self.client.post(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_201_CREATED + + result = self.result(res) + + assert not result['data']['id'] is None + + assert ( + int(result['data']['relationships']['user']['data']['id']) == + int(self.user.id) + ) + + def test_absence_update(self): + """Should update and existing absence.""" + absence = self.absences[0] + + absence.date = datetime.date(2017, 5, 5) + absence.save() + + data = { + 'data': { + 'type': 'absences', + 'id': absence.id, + 'attributes': { + 'date': datetime.date(2017, 5, 6).strftime('%Y-%m-%d') + } + } + } + + url = reverse('absence-detail', args=[ + absence.id + ]) + + noauth_res = self.noauth_client.patch(url, data) + res = self.client.patch(url, data) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert ( + result['data']['attributes']['date'] == + data['data']['attributes']['date'] + ) + + def test_absence_delete(self): + """Should delete an absence.""" + absence = self.absences[0] + + url = reverse('absence-detail', args=[ + absence.id + ]) + + noauth_res = self.noauth_client.delete(url) + res = self.client.delete(url) + + assert noauth_res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == HTTP_204_NO_CONTENT + + def test_absence_fill_worktime(self): + """Should create an absence which fills the worktime.""" + date = datetime.date(2017, 5, 10) + type = AbsenceTypeFactory.create(fill_worktime=True) + employment = Employment.employment_at(self.user, date) + + employment.worktime_per_day = datetime.timedelta(hours=8) + employment.save() + + ReportFactory.create( + user=self.user, + date=date, + duration=datetime.timedelta(hours=5) + ) + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id + } + } + } + } + } + + url = reverse('absence-list') + + res = self.client.post(url, data) + + assert res.status_code == HTTP_201_CREATED + + result = self.result(res) + + assert result['data']['attributes']['duration'] == '03:00:00' + + def test_absence_weekend(self): + """Should not be able to create an absence on a weekend.""" + type = AbsenceTypeFactory.create() + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': datetime.date(2017, 5, 15).strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id + } + } + } + } + } + + url = reverse('absence-list') + + res = self.client.post(url, data) + + assert res.status_code == HTTP_400_BAD_REQUEST + + def test_absence_public_holiday(self): + """Should not be able to create an absence on a public holiday.""" + date = datetime.date(2017, 5, 16) + + type = AbsenceTypeFactory.create() + + PublicHolidayFactory.create( + location=Employment.employment_at(self.user, date).location, + date=date + ) + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id + } + } + } + } + } + + url = reverse('absence-list') + + res = self.client.post(url, data) + + assert res.status_code == HTTP_400_BAD_REQUEST diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index f38851377..1968a7710 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1,18 +1,18 @@ """Tests for the reports endpoint.""" -from datetime import timedelta +from datetime import date, timedelta from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.utils.duration import duration_string from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, - HTTP_401_UNAUTHORIZED) + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) -from timed.employment.factories import AbsenceTypeFactory +from timed.employment.factories import AbsenceTypeFactory, EmploymentFactory from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import TaskFactory -from timed.tracking.factories import ReportFactory +from timed.tracking.factories import AbsenceFactory, ReportFactory +from timed.tracking.models import Absence, Report class ReportTests(JSONAPITestCase): @@ -107,7 +107,7 @@ def test_report_update(self): """Should update an existing report.""" report = self.reports[0] - absence_type = AbsenceTypeFactory.create() + task = TaskFactory.create() data = { 'data': { @@ -120,12 +120,9 @@ def test_report_update(self): }, 'relationships': { 'task': { - 'data': None - }, - 'absence-type': { 'data': { - 'type': 'absence-types', - 'id': absence_type.id + 'type': 'tasks', + 'id': task.id } } } @@ -159,7 +156,10 @@ def test_report_update(self): data['data']['attributes']['date'] ) - assert result['data']['relationships']['task']['data'] is None + assert ( + int(result['data']['relationships']['task']['data']['id']) == + int(data['data']['relationships']['task']['data']['id']) + ) def test_report_delete(self): """Should delete a report.""" @@ -175,34 +175,6 @@ def test_report_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert user_res.status_code == HTTP_204_NO_CONTENT - def test_report_task_or_absence_type_required(self): - """Should not be able to save reports without task or absence type.""" - report = self.reports[0] - - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': {}, - 'relationships': { - 'task': { - 'data': None - }, - 'absence-type': { - 'data': None - } - } - } - } - - url = reverse('report-detail', args=[ - report.id - ]) - - res = self.client.patch(url, data) - - assert res.status_code == HTTP_400_BAD_REQUEST - def test_report_round_duration(self): """Should round the duration of a report to 15 minutes.""" report = self.reports[0] @@ -221,3 +193,25 @@ def test_report_round_duration(self): report.save() assert duration_string(report.duration) == '02:00:00' + + def test_absence_update_on_create_report(self): + """Should update the absence after creating a new report.""" + task = TaskFactory.create() + type = AbsenceTypeFactory.create(fill_worktime=True) + day = date(2017, 5, 3) + + employment = EmploymentFactory.create(user=self.user, start_date=day) + + absence = AbsenceFactory.create(user=self.user, date=day, type=type) + + Report.objects.create( + user=self.user, + date=day, + task=task, + duration=timedelta(hours=1) + ) + + assert ( + Absence.objects.get(pk=absence.pk).duration == + employment.worktime_per_day - timedelta(hours=1) + ) diff --git a/timed/tracking/urls.py b/timed/tracking/urls.py index 7f160fe4d..2e3e5be60 100644 --- a/timed/tracking/urls.py +++ b/timed/tracking/urls.py @@ -11,5 +11,6 @@ r.register(r'activity-blocks', views.ActivityBlockViewSet, 'activity-block') r.register(r'attendances', views.AttendanceViewSet, 'attendance') r.register(r'reports', views.ReportViewSet, 'report') +r.register(r'absences', views.AbsenceViewSet, 'absence') urlpatterns = r.urls diff --git a/timed/tracking/views.py b/timed/tracking/views.py index ba0b5d810..dae47b3af 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -65,3 +65,18 @@ def get_queryset(self): :rtype: QuerySet """ return models.Report.objects.filter(user=self.request.user) + + +class AbsenceViewSet(ModelViewSet): + """Absence view set.""" + + serializer_class = serializers.AbsenceSerializer + filter_class = filters.AbsenceFilterSet + + def get_queryset(self): + """Filter the queryset by the user of the request. + + :return: The filtered absences + :rtype: QuerySet + """ + return models.Absence.objects.filter(user=self.request.user) From 0ce2431c2961d4b59e1c895cd7edf4b2d8a33c79 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 11 May 2017 10:03:49 +0200 Subject: [PATCH 074/980] Use custom manager for determining employments at a certain date --- timed/employment/models.py | 44 +++++++++++------------ timed/employment/serializers.py | 34 +++++++++--------- timed/employment/tests/test_employment.py | 6 ++-- timed/tracking/factories.py | 2 +- timed/tracking/models.py | 2 +- timed/tracking/serializers.py | 2 +- timed/tracking/tests/test_absence.py | 4 +-- 7 files changed, 48 insertions(+), 46 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 02b35d4e7..111180b85 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -5,6 +5,27 @@ from django.db import models +class EmploymentManager(models.Manager): + """Custom manager for employments.""" + + def at(self, user, date): + """Get the employment on a date for a user. + + :param User user: The user of the searched employment + :param datetime.date date: The date of the searched employment + :returns: The employment on the date for the user + :rtype: timed.employment.models.Employment + """ + return self.get( + ( + models.Q(end_date__gte=date) | + models.Q(end_date__isnull=True) + ), + start_date__lte=date, + user=user + ) + + class Location(models.Model): """Location model. @@ -38,28 +59,7 @@ class Employment(models.Model): worktime_per_day = models.DurationField() start_date = models.DateField() end_date = models.DateField(blank=True, null=True) - - @classmethod - def employment_at(cls, user, date): - """Get the employment on a date for a user. - - :returns: The employment on the date for the user - :rtype: timed.employment.models.Employment - """ - try: - return cls.objects.get( - ( - models.Q(end_date__gte=date) | - models.Q(end_date__isnull=True) - ), - start_date__lte=date, - user=user - ) - except Exception: - raise Exception('User {0} had no employment on {1}'.format( - user.username, - date.strftime('%Y-%m-%d') - )) + objects = EmploymentManager() def __str__(self): """Represent the model as a string. diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 595791e8b..df1cc5472 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -23,22 +23,24 @@ class UserSerializer(ModelSerializer): def get_worktime_balance_raw(self, instance): """Calculate the worktime balance for the user. - 1. Determine the current employment of the user - 2. Take the latest of those two as start date: - * The start of the year - * The start of the current employment - 3. Take the delivered date if given or the current date as end date - 4. Determine the count of workdays within start and end date - 5. Determine the count of public holidays within start and end date - 6. The expected worktime consists of following elements: - * Workdays - * Subtracted by holidays - * Multiplicated with the worktime per day of the employment - 7. Determine the overtime credit duration within start and end date - 8. The reported worktime is the sum of the durations of all reports for - this user within start and end date - 9. The balance is the reported time plus the overtime credit minus the - expected worktime + 1. Determine the current employment of the user + 2. Take the latest of those two as start date: + * The start of the year + * The start of the current employment + 3. Take the delivered date if given or the current date as end date + 4. Determine the count of workdays within start and end date + 5. Determine the count of public holidays within start and end date + 6. The expected worktime consists of following elements: + * Workdays + * Subtracted by holidays + * Multiplicated with the worktime per day of the employment + 7. Determine the overtime credit duration within start and end date + 8. The reported worktime is the sum of the durations of all reports + for this user within start and end date + 9. The absences are all absences for this user between the start and + end time + 10. The balance is the reported time plus the absences plus the + overtime credit minus the expected worktime :returns: The worktime balance of the user :rtype: datetime.timedelta diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index f56a3d7e2..656212dde 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -124,7 +124,7 @@ def test_employment_at(self): end_date__isnull=True) assert ( - Employment.employment_at(self.user, employment.start_date) == + Employment.objects.at(self.user, employment.start_date) == employment ) @@ -135,8 +135,8 @@ def test_employment_at(self): employment.save() - with pytest.raises(Exception): - Employment.employment_at( + with pytest.raises(Employment.DoesNotExist): + Employment.objects.at( self.user, employment.start_date + datetime.timedelta(days=21) ) diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 32fde5062..d2f79aa34 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -157,7 +157,7 @@ def duration(self): :return: The computed duration :rtype: datetime.timedelta """ - return Employment.employment_at(self.user, self.date).worktime_per_day + return Employment.objects.at(self.user, self.date).worktime_per_day class Meta: """Meta informations for the absence factory.""" diff --git a/timed/tracking/models.py b/timed/tracking/models.py index b09c01b33..163674dc0 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -191,7 +191,7 @@ def save(self, *args, **kwargs): sickness), in which case the duration of the absence needs to fill the difference between the reported time and the worktime per day. """ - employment = Employment.employment_at(self.user, self.date) + employment = Employment.objects.at(self.user, self.date) if self.type.fill_worktime: worktime = sum( diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index f1e30ad2d..b5f9bc0dd 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -158,7 +158,7 @@ def validate(self, data): :rtype: dict """ if PublicHoliday.objects.filter( - location=Employment.employment_at( + location=Employment.objects.at( data.get('user'), data.get('date') ).location, diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index f11bc1e4b..379da16d2 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -170,7 +170,7 @@ def test_absence_fill_worktime(self): """Should create an absence which fills the worktime.""" date = datetime.date(2017, 5, 10) type = AbsenceTypeFactory.create(fill_worktime=True) - employment = Employment.employment_at(self.user, date) + employment = Employment.objects.at(self.user, date) employment.worktime_per_day = datetime.timedelta(hours=8) employment.save() @@ -244,7 +244,7 @@ def test_absence_public_holiday(self): type = AbsenceTypeFactory.create() PublicHolidayFactory.create( - location=Employment.employment_at(self.user, date).location, + location=Employment.objects.at(self.user, date).location, date=date ) From 66010ac7acd1030dfc7bdccc8c33a66af055bc3d Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 11 May 2017 10:49:10 +0200 Subject: [PATCH 075/980] Renamed employment at to for_user --- timed/employment/models.py | 4 +++- timed/employment/tests/test_employment.py | 4 ++-- timed/tracking/factories.py | 5 ++++- timed/tracking/models.py | 2 +- timed/tracking/serializers.py | 2 +- timed/tracking/tests/test_absence.py | 4 ++-- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 111180b85..8d5f79446 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -1,5 +1,7 @@ """Models for the employment app.""" +import datetime + from django.conf import settings from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -8,7 +10,7 @@ class EmploymentManager(models.Manager): """Custom manager for employments.""" - def at(self, user, date): + def for_user(self, user, date=datetime.date.today()): """Get the employment on a date for a user. :param User user: The user of the searched employment diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 656212dde..3edae1760 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -124,7 +124,7 @@ def test_employment_at(self): end_date__isnull=True) assert ( - Employment.objects.at(self.user, employment.start_date) == + Employment.objects.for_user(self.user, employment.start_date) == employment ) @@ -136,7 +136,7 @@ def test_employment_at(self): employment.save() with pytest.raises(Employment.DoesNotExist): - Employment.objects.at( + Employment.objects.for_user( self.user, employment.start_date + datetime.timedelta(days=21) ) diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index d2f79aa34..cfbb9b7a0 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -157,7 +157,10 @@ def duration(self): :return: The computed duration :rtype: datetime.timedelta """ - return Employment.objects.at(self.user, self.date).worktime_per_day + return Employment.objects.for_user( + self.user, + self.date + ).worktime_per_day class Meta: """Meta informations for the absence factory.""" diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 163674dc0..2b221adba 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -191,7 +191,7 @@ def save(self, *args, **kwargs): sickness), in which case the duration of the absence needs to fill the difference between the reported time and the worktime per day. """ - employment = Employment.objects.at(self.user, self.date) + employment = Employment.objects.for_user(self.user, self.date) if self.type.fill_worktime: worktime = sum( diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index b5f9bc0dd..e030554b3 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -158,7 +158,7 @@ def validate(self, data): :rtype: dict """ if PublicHoliday.objects.filter( - location=Employment.objects.at( + location=Employment.objects.for_user( data.get('user'), data.get('date') ).location, diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 379da16d2..495a5d87c 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -170,7 +170,7 @@ def test_absence_fill_worktime(self): """Should create an absence which fills the worktime.""" date = datetime.date(2017, 5, 10) type = AbsenceTypeFactory.create(fill_worktime=True) - employment = Employment.objects.at(self.user, date) + employment = Employment.objects.for_user(self.user, date) employment.worktime_per_day = datetime.timedelta(hours=8) employment.save() @@ -244,7 +244,7 @@ def test_absence_public_holiday(self): type = AbsenceTypeFactory.create() PublicHolidayFactory.create( - location=Employment.objects.at(self.user, date).location, + location=Employment.objects.for_user(self.user, date).location, date=date ) From ade47eb67aeacd4a68ed070dcd677e3c52c63d5d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 11 May 2017 16:25:39 +0200 Subject: [PATCH 076/980] Changed comment field on report and activity to text * this avoid restriction which might not be desired by user * as we are not in production yet and migrations caused issues started from a clean db again --- timed/employment/migrations/0001_initial.py | 37 +++++++++- .../migrations/0002_auto_20170323_1031.py | 52 -------------- .../0003_absencetype_fill_worktime.py | 20 ------ timed/projects/migrations/0001_initial.py | 2 +- timed/tracking/migrations/0001_initial.py | 29 ++++++-- .../migrations/0002_auto_20170323_1031.py | 32 --------- .../migrations/0003_auto_20170324_1505.py | 20 ------ .../migrations/0004_auto_20170501_1551.py | 21 ------ .../migrations/0005_auto_20170504_1259.py | 21 ------ .../migrations/0006_auto_20170508_1045.py | 68 ------------------- timed/tracking/models.py | 4 +- 11 files changed, 62 insertions(+), 244 deletions(-) delete mode 100644 timed/employment/migrations/0002_auto_20170323_1031.py delete mode 100644 timed/employment/migrations/0003_absencetype_fill_worktime.py delete mode 100644 timed/tracking/migrations/0002_auto_20170323_1031.py delete mode 100644 timed/tracking/migrations/0003_auto_20170324_1505.py delete mode 100644 timed/tracking/migrations/0004_auto_20170501_1551.py delete mode 100644 timed/tracking/migrations/0005_auto_20170504_1259.py delete mode 100644 timed/tracking/migrations/0006_auto_20170508_1045.py diff --git a/timed/employment/migrations/0001_initial.py b/timed/employment/migrations/0001_initial.py index e75afc085..407e14a50 100644 --- a/timed/employment/migrations/0001_initial.py +++ b/timed/employment/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-02-20 11:55 +# Generated by Django 1.10.4 on 2017-05-11 14:23 from __future__ import unicode_literals from django.conf import settings @@ -17,6 +17,22 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='AbsenceCredit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('duration', models.DurationField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='AbsenceType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('fill_worktime', models.BooleanField(default=False)), + ], + ), migrations.CreateModel( name='Employment', fields=[ @@ -34,6 +50,15 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=50)), ], ), + migrations.CreateModel( + name='OvertimeCredit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('duration', models.DurationField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='overtime_credits', to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='PublicHoliday', fields=[ @@ -53,4 +78,14 @@ class Migration(migrations.Migration): name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employments', to=settings.AUTH_USER_MODEL), ), + migrations.AddField( + model_name='absencecredit', + name='absence_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.AbsenceType'), + ), + migrations.AddField( + model_name='absencecredit', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to=settings.AUTH_USER_MODEL), + ), ] diff --git a/timed/employment/migrations/0002_auto_20170323_1031.py b/timed/employment/migrations/0002_auto_20170323_1031.py deleted file mode 100644 index d306d7349..000000000 --- a/timed/employment/migrations/0002_auto_20170323_1031.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-03-23 09:31 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('employment', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='AbsenceCredit', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('duration', models.DurationField(blank=True, null=True)), - ], - ), - migrations.CreateModel( - name='AbsenceType', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), - ], - ), - migrations.CreateModel( - name='OvertimeCredit', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('duration', models.DurationField(blank=True, null=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='overtime_credits', to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.AddField( - model_name='absencecredit', - name='absence_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.AbsenceType'), - ), - migrations.AddField( - model_name='absencecredit', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/timed/employment/migrations/0003_absencetype_fill_worktime.py b/timed/employment/migrations/0003_absencetype_fill_worktime.py deleted file mode 100644 index c17ee582c..000000000 --- a/timed/employment/migrations/0003_absencetype_fill_worktime.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-08 08:45 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0002_auto_20170323_1031'), - ] - - operations = [ - migrations.AddField( - model_name='absencetype', - name='fill_worktime', - field=models.BooleanField(default=False), - ), - ] diff --git a/timed/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py index 64cd64be4..b74e1b83d 100644 --- a/timed/projects/migrations/0001_initial.py +++ b/timed/projects/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-02-20 11:55 +# Generated by Django 1.10.4 on 2017-05-11 14:23 from __future__ import unicode_literals from django.db import migrations, models diff --git a/timed/tracking/migrations/0001_initial.py b/timed/tracking/migrations/0001_initial.py index 3922de4a6..49c88b8f3 100644 --- a/timed/tracking/migrations/0001_initial.py +++ b/timed/tracking/migrations/0001_initial.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-02-20 11:55 +# Generated by Django 1.10.4 on 2017-05-11 14:23 from __future__ import unicode_literals +import datetime from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -12,18 +13,29 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('projects', '0001_initial'), + ('employment', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0001_initial'), ] operations = [ + migrations.CreateModel( + name='Absence', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('duration', models.DurationField(default=datetime.timedelta(0))), + ('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='employment.AbsenceType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='Activity', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('comment', models.CharField(blank=True, max_length=255)), + ('comment', models.TextField(blank=True)), ('start_datetime', models.DateTimeField(auto_now_add=True)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='projects.Task')), + ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='projects.Task')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL)), ], options={ @@ -52,13 +64,18 @@ class Migration(migrations.Migration): name='Report', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('comment', models.CharField(max_length=255)), + ('comment', models.TextField(blank=True)), ('date', models.DateField()), ('duration', models.DurationField()), ('review', models.BooleanField(default=False)), - ('nta', models.BooleanField(default=False)), + ('not_billable', models.BooleanField(default=False)), + ('activity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='tracking.Activity')), ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='projects.Task')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL)), ], ), + migrations.AlterUniqueTogether( + name='absence', + unique_together=set([('date', 'user')]), + ), ] diff --git a/timed/tracking/migrations/0002_auto_20170323_1031.py b/timed/tracking/migrations/0002_auto_20170323_1031.py deleted file mode 100644 index 2baf64202..000000000 --- a/timed/tracking/migrations/0002_auto_20170323_1031.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-03-23 09:31 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0002_auto_20170323_1031'), - ('tracking', '0001_initial'), - ] - - operations = [ - migrations.RenameField( - model_name='report', - old_name='nta', - new_name='not_billable', - ), - migrations.AddField( - model_name='report', - name='absence_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='employment.AbsenceType'), - ), - migrations.AddField( - model_name='report', - name='activity', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='tracking.Activity'), - ), - ] diff --git a/timed/tracking/migrations/0003_auto_20170324_1505.py b/timed/tracking/migrations/0003_auto_20170324_1505.py deleted file mode 100644 index 643ca0e6b..000000000 --- a/timed/tracking/migrations/0003_auto_20170324_1505.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-03-24 14:05 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0002_auto_20170323_1031'), - ] - - operations = [ - migrations.AlterField( - model_name='report', - name='comment', - field=models.CharField(blank=True, max_length=255), - ), - ] diff --git a/timed/tracking/migrations/0004_auto_20170501_1551.py b/timed/tracking/migrations/0004_auto_20170501_1551.py deleted file mode 100644 index 502fbf49d..000000000 --- a/timed/tracking/migrations/0004_auto_20170501_1551.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-01 13:51 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0003_auto_20170324_1505'), - ] - - operations = [ - migrations.AlterField( - model_name='report', - name='activity', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='tracking.Activity'), - ), - ] diff --git a/timed/tracking/migrations/0005_auto_20170504_1259.py b/timed/tracking/migrations/0005_auto_20170504_1259.py deleted file mode 100644 index 3637e833a..000000000 --- a/timed/tracking/migrations/0005_auto_20170504_1259.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-04 10:59 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0004_auto_20170501_1551'), - ] - - operations = [ - migrations.AlterField( - model_name='activity', - name='task', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='projects.Task'), - ), - ] diff --git a/timed/tracking/migrations/0006_auto_20170508_1045.py b/timed/tracking/migrations/0006_auto_20170508_1045.py deleted file mode 100644 index a82583a74..000000000 --- a/timed/tracking/migrations/0006_auto_20170508_1045.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-08 08:45 -from __future__ import unicode_literals - -from datetime import timedelta -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - -def migrate_absences_forwards(apps, schema_editor): - Report = apps.get_model('tracking', 'Report') - Absence = apps.get_model('tracking', 'Absence') - - for report in Report.objects.filter(absence_type__isnull=False): - Absence.objects.get_or_create( - user=report.user, - date=report.date, - defaults={ - 'type': report.absence_type, - 'duration': report.duration - } - ) - -def migrate_absences_reverse(apps, schema_editor): - Report = apps.get_model('tracking', 'Report') - Absence = apps.get_model('tracking', 'Absence') - - for absence in Absence.objects.all(): - Report.objects.create( - user=absence.user, - date=absence.date, - type=absence.absence_type, - duration=absence.duration - ) - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('employment', '0003_absencetype_fill_worktime'), - ('tracking', '0005_auto_20170504_1259'), - ] - - operations = [ - migrations.CreateModel( - name='Absence', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('duration', models.DurationField(default=timedelta())), - ('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='employment.AbsenceType')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.AlterUniqueTogether( - name='absence', - unique_together=set([('date', 'user')]), - ), - migrations.RunPython( - migrate_absences_forwards, - migrate_absences_reverse - ), - migrations.RemoveField( - model_name='report', - name='absence_type', - ), - ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 2b221adba..7aecb6f00 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -15,7 +15,7 @@ class Activity(models.Model): certain task. """ - comment = models.CharField(max_length=255, blank=True) + comment = models.TextField(blank=True) start_datetime = models.DateTimeField(auto_now_add=True) task = models.ForeignKey('projects.Task', null=True, @@ -118,7 +118,7 @@ class Report(models.Model): bill for the customer. """ - comment = models.CharField(max_length=255, blank=True) + comment = models.TextField(blank=True) date = models.DateField() duration = models.DurationField() review = models.BooleanField(default=False) From bee59503d682000044515634b1da576600461ed6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 12 May 2017 09:47:28 +0200 Subject: [PATCH 077/980] Updated requirements using django LTS version 1.11 --- requirements.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index c53777354..7b6993636 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -django==1.10.4 -django-auth-ldap==1.2.8 +django>=1.11,<1.12 +django-auth-ldap==1.2.11 django-crispy-forms==1.6.1 -django-filter==1.0.1 -django-jet==1.0.4 -djangorestframework==3.5.3 -djangorestframework-jsonapi==2.1.1 -djangorestframework-jwt==1.9.0 -psycopg2 -pytz +django-filter==1.0.2 +django-jet==1.0.6 +djangorestframework>=3.6,<3.7 +djangorestframework-jsonapi==2.2.0 +djangorestframework-jwt==1.10.0 +psycopg2>=2.7,<2.8 +pytz==2017.2 From 0fa44d8fe2ab196c8910e966e773732ea7915a22 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 12 May 2017 15:28:40 +0200 Subject: [PATCH 078/980] Some small fixes for absences --- timed/projects/views.py | 4 ++-- timed/tracking/filters.py | 5 ++++- .../migrations/0002_absence_comment.py | 20 +++++++++++++++++++ timed/tracking/models.py | 4 +++- timed/tracking/serializers.py | 1 + 5 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 timed/tracking/migrations/0002_absence_comment.py diff --git a/timed/projects/views.py b/timed/projects/views.py index b560bf2ac..e9b7dbba9 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -21,8 +21,7 @@ class ProjectViewSet(ReadOnlyModelViewSet): queryset = models.Project.objects.filter(archived=False) serializer_class = serializers.ProjectSerializer filter_class = filters.ProjectFilterSet - search_fields = ('name', 'customer__name',) - ordering = ('customer__name', 'name') + ordering = 'name' class TaskViewSet(ReadOnlyModelViewSet): @@ -31,3 +30,4 @@ class TaskViewSet(ReadOnlyModelViewSet): queryset = models.Task.objects.filter(archived=False) serializer_class = serializers.TaskSerializer filter_class = filters.TaskFilterSet + ordering = 'name' diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index e1fe279ef..9471e286e 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -122,8 +122,11 @@ class Meta: class AbsenceFilterSet(FilterSet): """Filter set for the absences endpoint.""" + from_date = DateFilter(name='date', lookup_expr='gte') + to_date = DateFilter(name='date', lookup_expr='lte') + class Meta: """Meta information for the absence filter set.""" model = models.Absence - fields = ['date'] + fields = ['date', 'from_date', 'to_date'] diff --git a/timed/tracking/migrations/0002_absence_comment.py b/timed/tracking/migrations/0002_absence_comment.py new file mode 100644 index 000000000..70d0fc20f --- /dev/null +++ b/timed/tracking/migrations/0002_absence_comment.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-05-12 13:11 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='absence', + name='comment', + field=models.TextField(blank=True), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 7aecb6f00..685d70060 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -176,6 +176,7 @@ class Absence(models.Model): worktime. E.g holidays or sickness. """ + comment = models.TextField(blank=True) date = models.DateField() duration = models.DurationField(default=timedelta()) type = models.ForeignKey('employment.AbsenceType', @@ -196,7 +197,8 @@ def save(self, *args, **kwargs): if self.type.fill_worktime: worktime = sum( Report.objects.filter( - date=self.date + date=self.date, + user=self.user ).values_list('duration', flat=True), timedelta() ) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index e030554b3..a416fe890 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -178,6 +178,7 @@ class Meta: model = models.Absence fields = [ + 'comment', 'date', 'duration', 'type', From f7bc82eea3c773d7797723dadb5a012782679fdb Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 15 May 2017 09:04:40 +0200 Subject: [PATCH 079/980] Added field to configure working days per location --- requirements.txt | 1 + .../migrations/0002_auto_20170512_1317.py | 26 ++++++++++++++++ timed/employment/models.py | 8 ++++- timed/employment/serializers.py | 6 ++-- timed/employment/tests/test_location.py | 6 +++- timed/models.py | 31 +++++++++++++++++++ timed/settings.py | 5 ++- .../django/forms/widgets/checkbox_option.html | 2 ++ timed/tracking/serializers.py | 13 +++++--- timed/tracking/tests/test_absence.py | 4 +-- 10 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 timed/employment/migrations/0002_auto_20170512_1317.py create mode 100644 timed/models.py create mode 100644 timed/templates/django/forms/widgets/checkbox_option.html diff --git a/requirements.txt b/requirements.txt index 7b6993636..c91344470 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ django-auth-ldap==1.2.11 django-crispy-forms==1.6.1 django-filter==1.0.2 django-jet==1.0.6 +django-multiselectfield==0.1.6 djangorestframework>=3.6,<3.7 djangorestframework-jsonapi==2.2.0 djangorestframework-jwt==1.10.0 diff --git a/timed/employment/migrations/0002_auto_20170512_1317.py b/timed/employment/migrations/0002_auto_20170512_1317.py new file mode 100644 index 000000000..18087d3e6 --- /dev/null +++ b/timed/employment/migrations/0002_auto_20170512_1317.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-12 11:17 +from __future__ import unicode_literals + +from django.db import migrations, models +import timed.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='workdays', + field=timed.models.WeekdaysField(choices=[(1, 'Monday'), (2, 'Tuesday'), (3, 'Wednesday'), (4, 'Thursday'), (5, 'Friday'), (6, 'Saturday'), (7, 'Sunday')], default=['1', '2', '3', '4', '5'], max_length=13), + ), + migrations.AlterField( + model_name='location', + name='name', + field=models.CharField(max_length=50, unique=True), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 8d5f79446..9e0b14cf0 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -6,6 +6,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from timed.models import WeekdaysField + class EmploymentManager(models.Manager): """Custom manager for employments.""" @@ -34,7 +36,11 @@ class Location(models.Model): A location is the place where an employee works. """ - name = models.CharField(max_length=50) + name = models.CharField(max_length=50, unique=True) + workdays = WeekdaysField(default=[str(day) for day in range(1, 6)]) + """ + Workdays defined per location, default is Monday - Friday + """ def __str__(self): """Represent the model as a string. diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index df1cc5472..7e04cafe7 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -60,11 +60,13 @@ def get_worktime_balance_raw(self, instance): else date.today() ) + # workdays is in isoweekday, byweekday expects Monday to be zero + week_workdays = [int(day) - 1 for day in employment.location.workdays] workdays = rrule.rrule( rrule.DAILY, dtstart=start_date, until=end_date, - byweekday=[1, 2, 3, 4, 5] + byweekday=week_workdays ).count() holidays = models.PublicHoliday.objects.filter( @@ -173,7 +175,7 @@ class Meta: """Meta information for the location serializer.""" model = models.Location - fields = ['name'] + fields = ['name', 'workdays'] class PublicHolidaySerializer(ModelSerializer): diff --git a/timed/employment/tests/test_location.py b/timed/employment/tests/test_location.py index 2e7f895a6..2570011b1 100644 --- a/timed/employment/tests/test_location.py +++ b/timed/employment/tests/test_location.py @@ -31,8 +31,12 @@ def test_location_list(self): assert res.status_code == HTTP_200_OK result = self.result(res) + data = result['data'] - assert len(result['data']) == len(self.locations) + assert len(data) == len(self.locations) + assert data[0]['attributes']['workdays'] == ( + [str(day) for day in range(1, 6)] + ) def test_location_detail(self): """Should respond with a single location.""" diff --git a/timed/models.py b/timed/models.py new file mode 100644 index 000000000..0ba779c55 --- /dev/null +++ b/timed/models.py @@ -0,0 +1,31 @@ +"""Basic model and field classes to be used in all apps.""" +from django.utils.translation import ugettext_lazy as _ +from multiselectfield import MultiSelectField + + +class WeekdaysField(MultiSelectField): + """ + Multi select field using weekdays as choices. + + Stores weekdays as comma-separated values in database as + iso week day (MON = 1, SUN = 7). + For django-jet to properly show multiple choices in admin + it is necessary to overwrite checkbox_option.html template. + """ + + MO, TU, WE, TH, FR, SA, SU = range(1, 8) + + WEEKDAYS = ( + (MO, _('Monday')), + (TU, _('Tuesday')), + (WE, _('Wednesday')), + (TH, _('Thursday')), + (FR, _('Friday')), + (SA, _('Saturday')), + (SU, _('Sunday')) + ) + + def __init__(self, *args, **kwargs): + """Initialize multi select with choices weekdays.""" + kwargs['choices'] = self.WEEKDAYS + super().__init__(*args, **kwargs) diff --git a/timed/settings.py b/timed/settings.py index 974f9e5b8..8961ff2c8 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -32,6 +32,8 @@ INSTALLED_APPS = [ 'jet', 'django.contrib.admin', + 'multiselectfield', + 'django.forms', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -58,10 +60,11 @@ ROOT_URLCONF = 'timed.urls' +FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(BASE_DIR, 'timed', 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ diff --git a/timed/templates/django/forms/widgets/checkbox_option.html b/timed/templates/django/forms/widgets/checkbox_option.html new file mode 100644 index 000000000..cedbc88eb --- /dev/null +++ b/timed/templates/django/forms/widgets/checkbox_option.html @@ -0,0 +1,2 @@ +{% include "django/forms/widgets/input.html" %} +{% if wrap_label %}{{ widget.label }}{% endif %} diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index e030554b3..883e98264 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -157,18 +157,21 @@ def validate(self, data): :returns: The validated data :rtype: dict """ + location = Employment.objects.for_user( + data.get('user'), + data.get('date') + ).location + if PublicHoliday.objects.filter( - location=Employment.objects.for_user( - data.get('user'), - data.get('date') - ).location, + location_id=location.id, date=data.get('date') ).exists(): raise ValidationError( 'You can\'t create an absence on a public holiday' ) - if data.get('date').weekday() not in [1, 2, 3, 4, 5]: + workdays = [int(day) for day in location.workdays] + if data.get('date').isoweekday() not in workdays: raise ValidationError('You can\'t create an absence on a weekend') return data diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 495a5d87c..1f88a30d2 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -130,7 +130,7 @@ def test_absence_update(self): 'type': 'absences', 'id': absence.id, 'attributes': { - 'date': datetime.date(2017, 5, 6).strftime('%Y-%m-%d') + 'date': datetime.date(2017, 5, 8).strftime('%Y-%m-%d') } } } @@ -218,7 +218,7 @@ def test_absence_weekend(self): 'type': 'absences', 'id': None, 'attributes': { - 'date': datetime.date(2017, 5, 15).strftime('%Y-%m-%d') + 'date': datetime.date(2017, 5, 14).strftime('%Y-%m-%d') }, 'relationships': { 'type': { From 120bbe258ad1d61420c66b985c39859f3744e2e4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 15 May 2017 10:40:20 +0200 Subject: [PATCH 080/980] Public holidays on a non workday should not be subtracted from worktime balance --- timed/employment/serializers.py | 12 ++++++++++-- timed/employment/tests/test_user.py | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 7e04cafe7..5fde8e7f6 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -49,6 +49,7 @@ def get_worktime_balance_raw(self, instance): user=instance, end_date__isnull=True ) + location = employment.location request = self.context.get('request') requested_end_date = request.query_params.get('until') @@ -69,10 +70,17 @@ def get_worktime_balance_raw(self, instance): byweekday=week_workdays ).count() + # converting workdays as db expects 1 (Sunday) to 7 (Saturday) + workdays_db = [ + # special case for Sunday + int(day) == 7 and 1 or int(day) + 1 + for day in location.workdays + ] holidays = models.PublicHoliday.objects.filter( - location=employment.location, + location=location, date__gte=start_date, - date__lte=end_date + date__lte=end_date, + date__week_day__in=workdays_db ).count() expected_worktime = employment.worktime_per_day * (workdays - holidays) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 0798f21c3..8372676ec 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -131,7 +131,12 @@ def test_user_worktime_balance(self): duration=timedelta(hours=10, minutes=30) ) - # One public holiday + # One public holiday during workdays + PublicHolidayFactory.create( + date=start_date, + location=employment.location + ) + # One public holiday on weekend PublicHolidayFactory.create( date=start_date + timedelta(days=1), location=employment.location From 692015786f82ff6ecfc89c1e28aeccdf850a982c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 19 May 2017 15:47:33 +0200 Subject: [PATCH 081/980] Added some performance enhancing --- .../migrations/0003_auto_20170519_1537.py | 23 +++++++++++ timed/employment/models.py | 10 +++++ timed/employment/views.py | 38 ++++++++++++++--- .../migrations/0002_auto_20170519_1537.py | 27 ++++++++++++ timed/projects/models.py | 15 +++++++ timed/projects/serializers.py | 5 +-- timed/projects/views.py | 41 +++++++++++++++++-- .../migrations/0003_auto_20170518_1059.py | 23 +++++++++++ timed/tracking/models.py | 30 +++++--------- timed/tracking/views.py | 36 +++++++++++++--- 10 files changed, 210 insertions(+), 38 deletions(-) create mode 100644 timed/employment/migrations/0003_auto_20170519_1537.py create mode 100644 timed/projects/migrations/0002_auto_20170519_1537.py create mode 100644 timed/tracking/migrations/0003_auto_20170518_1059.py diff --git a/timed/employment/migrations/0003_auto_20170519_1537.py b/timed/employment/migrations/0003_auto_20170519_1537.py new file mode 100644 index 000000000..911af3b88 --- /dev/null +++ b/timed/employment/migrations/0003_auto_20170519_1537.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-19 13:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0002_auto_20170512_1317'), + ] + + operations = [ + migrations.AddIndex( + model_name='employment', + index=models.Index(fields=['start_date', 'end_date'], name='employment__start_d_74c274_idx'), + ), + migrations.AddIndex( + model_name='publicholiday', + index=models.Index(fields=['date'], name='employment__date_2d002c_idx'), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 9e0b14cf0..ee9a11e50 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -81,6 +81,11 @@ def __str__(self): self.end_date.strftime('%d.%m.%Y') if self.end_date else 'today' ) + class Meta: + """Meta information for the employment model.""" + + indexes = [models.Index(fields=['start_date', 'end_date'])] + class PublicHoliday(models.Model): """Public holiday model. @@ -102,6 +107,11 @@ def __str__(self): """ return '{0} {1}'.format(self.name, self.date.strftime('%Y')) + class Meta: + """Meta information for the public holiday model.""" + + indexes = [models.Index(fields=['date'])] + class AbsenceType(models.Model): """Absence type model. diff --git a/timed/employment/views.py b/timed/employment/views.py index 89f5c9e15..93a0d5312 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -19,14 +19,19 @@ def get_queryset(self): :return: The filtered users :rtype: QuerySet """ - return get_user_model().objects.filter(pk=self.request.user.pk) + return get_user_model().objects.prefetch_related( + 'employments', + 'absence_credits' + ).filter( + pk=self.request.user.pk + ) class EmploymentViewSet(ReadOnlyModelViewSet): """Employment view set.""" serializer_class = serializers.EmploymentSerializer - ordering = ('-start_date',) + ordering = ('-end_date',) def get_queryset(self): """Filter the queryset by the user of the request. @@ -34,7 +39,12 @@ def get_queryset(self): :return: The filtered employments :rtype: QuerySet """ - return models.Employment.objects.filter(user=self.request.user) + return models.Employment.objects.select_related( + 'user', + 'location' + ).filter( + user=self.request.user + ) class LocationViewSet(ReadOnlyModelViewSet): @@ -48,11 +58,20 @@ class LocationViewSet(ReadOnlyModelViewSet): class PublicHolidayViewSet(ReadOnlyModelViewSet): """Public holiday view set.""" - queryset = models.PublicHoliday.objects.all() serializer_class = serializers.PublicHolidaySerializer filter_class = filters.PublicHolidayFilterSet ordering = ('date',) + def get_queryset(self): + """Prefetch the related data. + + :return: The public holidays + :rtype: QuerySet + """ + return models.PublicHoliday.objects.select_related( + 'location' + ).all() + class AbsenceTypeViewSet(ReadOnlyModelViewSet): """Absence type view set.""" @@ -81,7 +100,10 @@ def get_queryset(self): else date.today() ) - return models.AbsenceCredit.objects.filter( + return models.AbsenceCredit.objects.select_related( + 'user', + 'absence_type' + ).filter( user=self.request.user, date__lte=end_date ) @@ -98,4 +120,8 @@ def get_queryset(self): :return: The filtered overtime credits :rtype: QuerySet """ - return models.OvertimeCredit.objects.filter(user=self.request.user) + return models.OvertimeCredit.objects.select_related( + 'user' + ).filter( + user=self.request.user + ) diff --git a/timed/projects/migrations/0002_auto_20170519_1537.py b/timed/projects/migrations/0002_auto_20170519_1537.py new file mode 100644 index 000000000..3a8001c48 --- /dev/null +++ b/timed/projects/migrations/0002_auto_20170519_1537.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-19 13:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['name', 'archived'], name='projects_ta_name_dd9620_idx'), + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['name', 'archived'], name='projects_cu_name_e0e97a_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['name', 'archived'], name='projects_pr_name_ac60a8_idx'), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 8c4c24376..71863a271 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -26,6 +26,11 @@ def __str__(self): """ return self.name + class Meta: + """Meta informations for the customer model.""" + + indexes = [models.Index(fields=['name', 'archived'])] + class Project(models.Model): """Project model. @@ -54,6 +59,11 @@ def __str__(self): """ return '{0} > {1}'.format(self.customer, self.name) + class Meta: + """Meta informations for the project model.""" + + indexes = [models.Index(fields=['name', 'archived'])] + class Task(models.Model): """Task model. @@ -76,6 +86,11 @@ def __str__(self): """ return '{0} > {1}'.format(self.project, self.name) + class Meta: + """Meta informations for the task model.""" + + indexes = [models.Index(fields=['name', 'archived'])] + class TaskTemplate(models.Model): """Task template model. diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 13efa0c4e..f721e78fe 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -59,9 +59,7 @@ class Meta: class TaskSerializer(ModelSerializer): """Task serializer.""" - activities = ResourceRelatedField(read_only=True, - many=True) - project = ResourceRelatedField(queryset=models.Project.objects.all()) + project = ResourceRelatedField(queryset=models.Project.objects.all()) included_serializers = { 'activities': 'timed.tracking.serializers.ActivitySerializer', @@ -77,5 +75,4 @@ class Meta: 'estimated_hours', 'archived', 'project', - 'activities', ] diff --git a/timed/projects/views.py b/timed/projects/views.py index e9b7dbba9..a272a1531 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -8,26 +8,61 @@ class CustomerViewSet(ReadOnlyModelViewSet): """Customer view set.""" - queryset = models.Customer.objects.filter(archived=False) serializer_class = serializers.CustomerSerializer filter_class = filters.CustomerFilterSet search_fields = ('name',) ordering = 'name' + def get_queryset(self): + """Prefetch related data. + + :return: The customers + :rtype: QuerySet + """ + return models.Customer.objects.prefetch_related( + 'projects' + ).filter( + archived=False + ) + class ProjectViewSet(ReadOnlyModelViewSet): """Project view set.""" - queryset = models.Project.objects.filter(archived=False) serializer_class = serializers.ProjectSerializer filter_class = filters.ProjectFilterSet ordering = 'name' + def get_queryset(self): + """Prefetch related data. + + :return: The projects + :rtype: QuerySet + """ + return models.Project.objects.prefetch_related( + 'tasks' + ).select_related( + 'customer' + ).filter( + archived=False + ) + class TaskViewSet(ReadOnlyModelViewSet): """Task view set.""" - queryset = models.Task.objects.filter(archived=False) serializer_class = serializers.TaskSerializer filter_class = filters.TaskFilterSet ordering = 'name' + + def get_queryset(self): + """Prefetch related data. + + :return: The tasks + :rtype: QuerySet + """ + return models.Task.objects.select_related( + 'project' + ).filter( + archived=False + ) diff --git a/timed/tracking/migrations/0003_auto_20170518_1059.py b/timed/tracking/migrations/0003_auto_20170518_1059.py new file mode 100644 index 000000000..67ee2aade --- /dev/null +++ b/timed/tracking/migrations/0003_auto_20170518_1059.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-18 08:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0002_absence_comment'), + ] + + operations = [ + migrations.AddIndex( + model_name='report', + index=models.Index(fields=['date'], name='tracking_re_date_8770c2_idx'), + ), + migrations.AddIndex( + model_name='activity', + index=models.Index(fields=['start_datetime'], name='tracking_ac_start_d_c43640_idx'), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 685d70060..b7787b889 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import models +from django.db.models import F, Sum from timed.employment.models import Employment @@ -31,14 +32,9 @@ def duration(self): :return: The total duration :rtype: datetime.timedelta """ - durations = [ - block.duration - for block - in self.blocks.all() - if block.duration - ] - - return sum(durations, timedelta()) + return self.blocks.all().aggregate( + duration=Sum(F('to_datetime') - F('from_datetime')) + ).get('duration') def __str__(self): """Represent the model as a string. @@ -52,6 +48,7 @@ class Meta: """Meta informations for the activity model.""" verbose_name_plural = 'activities' + indexes = [models.Index(fields=['start_datetime'])] class ActivityBlock(models.Model): @@ -65,18 +62,6 @@ class ActivityBlock(models.Model): from_datetime = models.DateTimeField(auto_now_add=True) to_datetime = models.DateTimeField(blank=True, null=True) - @property - def duration(self): - """Calculate the duration of this activity block. - - :return: The duration - :rtype: datetime.timedelta or None - """ - if not self.to_datetime: - return None - - return self.to_datetime - self.from_datetime - def __str__(self): """Represent the model as a string. @@ -168,6 +153,11 @@ def __str__(self): """ return '{0}: {1}'.format(self.user, self.task) + class Meta: + """Meta information for the report model.""" + + indexes = [models.Index(fields=['date'])] + class Absence(models.Model): """Absence model. diff --git a/timed/tracking/views.py b/timed/tracking/views.py index dae47b3af..38319caa3 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -17,7 +17,16 @@ def get_queryset(self): :return: The filtered activities :rtype: QuerySet """ - return models.Activity.objects.filter(user=self.request.user) + return models.Activity.objects.prefetch_related( + 'blocks' + ).select_related( + 'task', + 'user', + 'task__project', + 'task__project__customer' + ).filter( + user=self.request.user + ) class ActivityBlockViewSet(ModelViewSet): @@ -32,7 +41,9 @@ def get_queryset(self): :return: The filtered activity blocks :rtype: QuerySet """ - return models.ActivityBlock.objects.filter( + return models.ActivityBlock.objects.select_related( + 'activity' + ).filter( activity__user=self.request.user ) @@ -49,7 +60,11 @@ def get_queryset(self): :return: The filtered attendances :rtype: QuerySet """ - return models.Attendance.objects.filter(user=self.request.user) + return models.Attendance.objects.select_related( + 'user' + ).filter( + user=self.request.user + ) class ReportViewSet(ModelViewSet): @@ -64,7 +79,13 @@ def get_queryset(self): :return: The filtered reports :rtype: QuerySet """ - return models.Report.objects.filter(user=self.request.user) + return models.Report.objects.select_related( + 'task', + 'user', + 'activity' + ).filter( + user=self.request.user + ) class AbsenceViewSet(ModelViewSet): @@ -79,4 +100,9 @@ def get_queryset(self): :return: The filtered absences :rtype: QuerySet """ - return models.Absence.objects.filter(user=self.request.user) + return models.Absence.objects.select_related( + 'type', + 'user' + ).filter( + user=self.request.user + ) From a16f930c3c99584d00f0752eb63bf2e4c2c3f06c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 19 May 2017 16:11:39 +0200 Subject: [PATCH 082/980] Removed useless .all() --- timed/employment/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/employment/views.py b/timed/employment/views.py index 93a0d5312..af0d9032a 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -70,7 +70,7 @@ def get_queryset(self): """ return models.PublicHoliday.objects.select_related( 'location' - ).all() + ) class AbsenceTypeViewSet(ReadOnlyModelViewSet): From fd2015755d855b60b2f5c268419708a27393fb65 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 9 Jun 2017 13:20:55 +0200 Subject: [PATCH 083/980] Removed useless includes --- timed/projects/serializers.py | 14 +------------- timed/projects/views.py | 5 +---- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index f721e78fe..ef19f4f17 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -9,13 +9,6 @@ class CustomerSerializer(ModelSerializer): """Customer serializer.""" - projects = ResourceRelatedField(read_only=True, - many=True) - - included_serializers = { - 'projects': 'timed.projects.serializers.ProjectSerializer' - } - class Meta: """Meta information for the customer serializer.""" @@ -26,7 +19,6 @@ class Meta: 'website', 'comment', 'archived', - 'projects', ] @@ -34,12 +26,9 @@ class ProjectSerializer(ModelSerializer): """Project serializer.""" customer = ResourceRelatedField(queryset=models.Customer.objects.all()) - tasks = ResourceRelatedField(read_only=True, - many=True) included_serializers = { - 'customer': 'timed.projects.serializers.CustomerSerializer', - 'tasks': 'timed.projects.serializers.TaskSerializer' + 'customer': 'timed.projects.serializers.CustomerSerializer' } class Meta: @@ -52,7 +41,6 @@ class Meta: 'estimated_hours', 'archived', 'customer', - 'tasks', ] diff --git a/timed/projects/views.py b/timed/projects/views.py index a272a1531..6cec4178b 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -10,7 +10,6 @@ class CustomerViewSet(ReadOnlyModelViewSet): serializer_class = serializers.CustomerSerializer filter_class = filters.CustomerFilterSet - search_fields = ('name',) ordering = 'name' def get_queryset(self): @@ -39,9 +38,7 @@ def get_queryset(self): :return: The projects :rtype: QuerySet """ - return models.Project.objects.prefetch_related( - 'tasks' - ).select_related( + return models.Project.objects.select_related( 'customer' ).filter( archived=False From 11fc649ef7c7bb2abdb741f4065167a8d999b40f Mon Sep 17 00:00:00 2001 From: Lucas Bickel Date: Mon, 12 Jun 2017 15:40:46 +0200 Subject: [PATCH 084/980] Extend JWT timeout to be less zealous Change JWT expiry timeouts to allow for users to stay logged in over a longer time than previously possible. The idea with these settings is to allow for the following use cases: * User doesn't get logged out during daily use * User only needs to log in once per week * A user is still logged after a free day during the week These timeouts where set in this fashion with usability being the primary aim. Due to the data expected in the system more strict security settings are not a consideration. --- timed/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index 8961ff2c8..83913c93c 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -181,8 +181,9 @@ ) JWT_AUTH = { - 'JWT_EXPIRATION_DELTA': datetime.timedelta(minutes=60), + 'JWT_EXPIRATION_DELTA': datetime.timedelta(days=2), 'JWT_ALLOW_REFRESH': True, + 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7), 'JWT_AUTH_HEADER_PREFIX': 'Bearer', } From ce8b56368f24c6ea1f9f3197ddaaa5469fd950f7 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 16 Jun 2017 16:58:56 +0200 Subject: [PATCH 085/980] Let the frontend handle the start of an activity block --- .../migrations/0004_auto_20170616_1622.py | 20 +++++++++++++++++++ timed/tracking/models.py | 2 +- timed/tracking/tests/test_activity_block.py | 10 ++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 timed/tracking/migrations/0004_auto_20170616_1622.py diff --git a/timed/tracking/migrations/0004_auto_20170616_1622.py b/timed/tracking/migrations/0004_auto_20170616_1622.py new file mode 100644 index 000000000..a0aaa106d --- /dev/null +++ b/timed/tracking/migrations/0004_auto_20170616_1622.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-06-16 14:22 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0003_auto_20170518_1059'), + ] + + operations = [ + migrations.AlterField( + model_name='activityblock', + name='from_datetime', + field=models.DateTimeField(), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index b7787b889..124301820 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -59,7 +59,7 @@ class ActivityBlock(models.Model): activity = models.ForeignKey('tracking.Activity', related_name='blocks') - from_datetime = models.DateTimeField(auto_now_add=True) + from_datetime = models.DateTimeField() to_datetime = models.DateTimeField(blank=True, null=True) def __str__(self): diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py index c0d399b58..3228c47c0 100644 --- a/timed/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -69,12 +69,15 @@ def test_activity_block_detail(self): def test_activity_block_create(self): """Should create a new activity block.""" activity = self.activity_blocks[0].activity + tz = timezone('Europe/Zurich') data = { 'data': { 'type': 'activity-blocks', 'id': None, - 'attributes': {}, + 'attributes': { + 'from-datetime': datetime.now(tz).isoformat() + }, 'relationships': { 'activity': { 'data': { @@ -148,6 +151,7 @@ def test_activity_block_delete(self): def test_activity_block_active_unique(self): """Should not be able to have two active blocks.""" block = self.activity_blocks[0] + tz = timezone('Europe/Zurich') block.to_datetime = None block.save() @@ -156,7 +160,9 @@ def test_activity_block_active_unique(self): 'data': { 'type': 'activity-blocks', 'id': None, - 'attributes': {}, + 'attributes': { + 'from-datetime': datetime.now(tz).isoformat() + }, 'relationships': { 'activity': { 'data': { From f3d5e20cb46256fcd26d1d141998b0bcca775c65 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 30 Jun 2017 15:19:14 +0200 Subject: [PATCH 086/980] Added filters for multiday absence feature --- timed/employment/filters.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 2d9476f52..e9bc487d6 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,7 +1,7 @@ """Filters for filtering the data of the employment app endpoints.""" -from django_filters import Filter, FilterSet +from django_filters import DateFilter, Filter, FilterSet from timed.employment import models @@ -25,10 +25,12 @@ def filter(self, qs, value): class PublicHolidayFilterSet(FilterSet): """Filter set for the public holidays endpoint.""" - year = YearFilter(name='date') + year = YearFilter(name='date') + from_date = DateFilter(name='date', lookup_expr='gte') + to_date = DateFilter(name='date', lookup_expr='lte') class Meta: """Meta information for the public holiday filter set.""" model = models.PublicHoliday - fields = ['year', 'location', 'date'] + fields = ['year', 'location', 'date', 'from_date', 'to_date'] From 766baa8920ad79d5d52f60fac752af6ce1667027 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 19 Jul 2017 13:40:57 +0200 Subject: [PATCH 087/980] Turning off forced requirement of docstrings This led to a lot of noise in the code without any further assistance. Whether a comment is required or not needs to be reviewed in a PR on a case by case basis. --- .flake8 | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 82b15f3f6..5ca67f793 100644 --- a/.flake8 +++ b/.flake8 @@ -3,7 +3,21 @@ ignore = # multiple spaces before operator E221, # multiple spaces after separator - E241 + E241, + # Missing docstring in public module + D100, + # Missing docstring in public class + D101, + # Missing docstring in public method + D102, + # Missing docstring in public function + D103, + # Missing docstring in public package + D104, + # Missing docstring in magic method + D105, + # Missing docstring in public package + D106 exclude = manage.py, From 0680e9e1130e08c8d0109ece5bdc967449c0e2ee Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 18 Jul 2017 15:57:45 +0200 Subject: [PATCH 088/980] Added additional filters to report end point Broken backwards compatibility as reports are now not per default filtered per user anymore. A user filter need to set manually to gain the same behavior. --- timed/jsonapi_test_case.py | 2 +- timed/tracking/filters.py | 10 +++++--- timed/tracking/permissions.py | 11 ++++++++ timed/tracking/tests/test_report.py | 39 ++++++++++++++++++++++++----- timed/tracking/views.py | 8 +++--- 5 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 timed/tracking/permissions.py diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 8bdbb0df5..cffce514a 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -33,7 +33,7 @@ def get(self, path, data=None, **kwargs): """ return super().get( path=path, - data=self._parse_data(data), + data=data, content_type=self._content_type, **kwargs ) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 9471e286e..d995551f0 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -3,7 +3,7 @@ import datetime from functools import wraps -from django_filters import DateFilter, Filter, FilterSet +from django_filters import DateFilter, Filter, FilterSet, NumberFilter from timed.tracking import models @@ -109,14 +109,16 @@ class Meta: class ReportFilterSet(FilterSet): """Filter set for the reports endpoint.""" - from_date = DateFilter(name='date', lookup_expr='gte') - to_date = DateFilter(name='date', lookup_expr='lte') + from_date = DateFilter(name='date', lookup_expr='gte') + to_date = DateFilter(name='date', lookup_expr='lte') + project = NumberFilter(name='task__project') + customer = NumberFilter(name='task__project__customer') class Meta: """Meta information for the report filter set.""" model = models.Report - fields = ['date', 'from_date', 'to_date'] + fields = ['date', 'from_date', 'to_date', 'user', 'task', 'project'] class AbsenceFilterSet(FilterSet): diff --git a/timed/tracking/permissions.py b/timed/tracking/permissions.py new file mode 100644 index 000000000..3bc3597ab --- /dev/null +++ b/timed/tracking/permissions.py @@ -0,0 +1,11 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsOwnerOrReadOnly(BasePermission): + """Changing an object is only allowed if object belongs to current user.""" + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + + return obj.user_id == request.user.id diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 1968a7710..5913bcb26 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -6,7 +6,8 @@ from django.core.urlresolvers import reverse from django.utils.duration import duration_string from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN) from timed.employment.factories import AbsenceTypeFactory, EmploymentFactory from timed.jsonapi_test_case import JSONAPITestCase @@ -28,22 +29,28 @@ def setUp(self): ) self.reports = ReportFactory.create_batch(10, user=self.user) - - ReportFactory.create_batch(10, user=other_user) + self.other_reports = ReportFactory.create_batch(10, user=other_user) def test_report_list(self): - """Should respond with a list of reports filtered by user.""" + """Should respond with a list of filtered reports.""" url = reverse('report-list') noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + user_res = self.client.get(url, data={ + 'date': self.reports[0].date, + 'user': self.user.id, + 'task': self.reports[0].task.id, + 'project': self.reports[0].task.project.id, + 'customer': self.reports[0].task.project.customer.id, + }) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert user_res.status_code == HTTP_200_OK result = self.result(user_res) - assert len(result['data']) == len(self.reports) + assert len(result['data']) == 1 + assert result['data'][0]['id'] == str(self.reports[0].id) def test_report_detail(self): """Should respond with a single report.""" @@ -161,6 +168,26 @@ def test_report_update(self): int(data['data']['relationships']['task']['data']['id']) ) + def test_report_update_invalid_user(self): + """Updating of report belonging to different user is not allowed.""" + report = self.other_reports[0] + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + user_res = self.client.patch(url, data) + assert user_res.status_code == HTTP_403_FORBIDDEN + def test_report_delete(self): """Should delete a report.""" report = self.reports[0] diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 38319caa3..3123f4587 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,8 +1,9 @@ """Viewsets for the tracking app.""" +from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet -from timed.tracking import filters, models, serializers +from timed.tracking import filters, models, permissions, serializers class ActivityViewSet(ModelViewSet): @@ -72,9 +73,10 @@ class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet + permission_classes = [IsAuthenticated, permissions.IsOwnerOrReadOnly] def get_queryset(self): - """Filter the queryset by the user of the request. + """Select related to reduce queries. :return: The filtered reports :rtype: QuerySet @@ -83,8 +85,6 @@ def get_queryset(self): 'task', 'user', 'activity' - ).filter( - user=self.request.user ) From c8a3f5bd4c32d2a48b09e28eed53e4e12d5c9f80 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 19 Jul 2017 13:10:47 +0200 Subject: [PATCH 089/980] Added export functionality of reports --- .travis.yml | 5 ++- dev_requirements.txt | 1 + requirements.txt | 5 +++ timed/tracking/tests/test_report.py | 63 +++++++++++++++++++++++++++-- timed/tracking/views.py | 35 ++++++++++++++++ 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index c958abe78..6e2c62a03 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,10 @@ python: services: - postgresql -cache: pip +cache: + - pip + - directories: + - .hypothesis install: - make install-dev diff --git a/dev_requirements.txt b/dev_requirements.txt index 37311f5ac..642f75922 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -8,6 +8,7 @@ flake8-docstrings flake8-isort flake8-quotes flake8-string-format +hypothesis==3.13.1 ipdb isort pytest diff --git a/requirements.txt b/requirements.txt index c91344470..b81497998 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,8 @@ djangorestframework-jsonapi==2.2.0 djangorestframework-jwt==1.10.0 psycopg2>=2.7,<2.8 pytz==2017.2 +pyexcel-webio==0.1.2 +pyexcel-io==0.4.2 +django-excel==0.0.9 +pyexcel-ods3==0.4.0 +pyexcel-xlsx==0.4.1 diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 5913bcb26..15280a7fe 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -2,15 +2,22 @@ from datetime import date, timedelta +import pyexcel from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.utils.duration import duration_string +from hypothesis import given, settings +from hypothesis.extra.django import TestCase +from hypothesis.extra.django.models import models +from hypothesis.strategies import (builds, characters, dates, sampled_from, + timedeltas) from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN) + HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) -from timed.employment.factories import AbsenceTypeFactory, EmploymentFactory -from timed.jsonapi_test_case import JSONAPITestCase +from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, + UserFactory) +from timed.jsonapi_test_case import JSONAPIClient, JSONAPITestCase from timed.projects.factories import TaskFactory from timed.tracking.factories import AbsenceFactory, ReportFactory from timed.tracking.models import Absence, Report @@ -52,6 +59,16 @@ def test_report_list(self): assert len(result['data']) == 1 assert result['data'][0]['id'] == str(self.reports[0].id) + def test_report_export_missing_type(self): + """Should respond with a list of filtered reports.""" + url = reverse('report-export') + + user_res = self.client.get(url, data={ + 'user': self.user.id, + }) + + assert user_res.status_code == HTTP_400_BAD_REQUEST + def test_report_detail(self): """Should respond with a single report.""" report = self.reports[0] @@ -242,3 +259,41 @@ def test_absence_update_on_create_report(self): Absence.objects.get(pk=absence.pk).duration == employment.worktime_per_day - timedelta(hours=1) ) + + +class TestReportHypo(TestCase): + @given( + sampled_from(['csv', 'xlsx', 'ods']), + models( + Report, + comment=characters(blacklist_categories=['Cc']), + task=builds(TaskFactory.create), + user=builds(UserFactory.create), + date=dates( + min_date=date(2000, 1, 1), + max_date=date(2100, 1, 1), + ), + duration=timedeltas( + min_delta=timedelta(0), + max_delta=timedelta(days=1) + ) + ) + ) + @settings(timeout=5) + def test_report_export(self, file_type, report): + User.objects.create_user(username='test', password='1234qwer') + client = JSONAPIClient() + client.login('test', '1234qwer') + url = reverse('report-export') + + user_res = client.get(url, data={ + 'file_type': file_type + }) + + assert user_res.status_code == HTTP_200_OK + book = pyexcel.get_book( + file_content=user_res.content, file_type=file_type + ) + # bookdict is a dict of tuples(name, content) + sheet = book.bookdict.popitem()[1] + assert len(sheet) == 2 diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 3123f4587..4f2c7ad11 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,5 +1,8 @@ """Viewsets for the tracking app.""" +import django_excel +from rest_framework.decorators import list_route +from rest_framework.exceptions import ParseError from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet @@ -75,6 +78,38 @@ class ReportViewSet(ModelViewSet): filter_class = filters.ReportFilterSet permission_classes = [IsAuthenticated, permissions.IsOwnerOrReadOnly] + @list_route() + def export(self, request): + """Export filtered reports to given file format.""" + queryset = self.filter_queryset(self.get_queryset()) + colnames = [ + 'Date', 'Duration', 'Customer', + 'Project', 'Task', 'User', 'Comment' + ] + content = [ + [ + report.date, + report.duration, + report.task.project.customer.name, + report.task.project.name, + report.task.name, + report.user.username, + report.comment, + ] + for report in queryset + ] + + file_type = request.query_params.get('file_type') + if file_type is None: + raise ParseError('Missing file_type parameter') + + sheet = django_excel.pe.Sheet( + content, name='Report', colnames=colnames + ) + return django_excel.make_response( + sheet, file_type=file_type, file_name='report.%s' % file_type + ) + def get_queryset(self): """Select related to reduce queries. From 2211feef233920d8122b3e4cbf760abfa82bb918 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 24 Jul 2017 09:50:50 +0200 Subject: [PATCH 090/980] Unicode surrogate letters are not allowed in reports --- timed/tracking/tests/test_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 15280a7fe..f0273640b 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -266,7 +266,7 @@ class TestReportHypo(TestCase): sampled_from(['csv', 'xlsx', 'ods']), models( Report, - comment=characters(blacklist_categories=['Cc']), + comment=characters(blacklist_categories=['Cc', 'Cs']), task=builds(TaskFactory.create), user=builds(UserFactory.create), date=dates( From 7c997ec41bf2540a86281d6155939dc23ea10084 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 24 Jul 2017 10:01:50 +0200 Subject: [PATCH 091/980] Check what file types are supported --- timed/tracking/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 4f2c7ad11..ee8866d02 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -100,8 +100,11 @@ def export(self, request): ] file_type = request.query_params.get('file_type') - if file_type is None: - raise ParseError('Missing file_type parameter') + if file_type not in ['csv', 'xlsx', 'ods']: + raise ParseError( + 'Invalid file_type parameter. ' + 'Only csv, xlsx and ods are supported.' + ) sheet = django_excel.pe.Sheet( content, name='Report', colnames=colnames From 4b494a9d2405a41857524cbfc9c203d36a34c136 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 24 Jul 2017 14:36:20 +0200 Subject: [PATCH 092/980] Remove jet and crispy forms dependency Jet and crispy forms do not add any more functionality but add higher complexity which makes updating of Django more difficult. --- requirements.txt | 2 -- timed/models.py | 2 -- timed/settings.py | 2 -- timed/templates/django/forms/widgets/checkbox_option.html | 2 -- timed/urls.py | 1 - 5 files changed, 9 deletions(-) delete mode 100644 timed/templates/django/forms/widgets/checkbox_option.html diff --git a/requirements.txt b/requirements.txt index b81497998..0a2d18554 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ django>=1.11,<1.12 django-auth-ldap==1.2.11 -django-crispy-forms==1.6.1 django-filter==1.0.2 -django-jet==1.0.6 django-multiselectfield==0.1.6 djangorestframework>=3.6,<3.7 djangorestframework-jsonapi==2.2.0 diff --git a/timed/models.py b/timed/models.py index 0ba779c55..0c7579121 100644 --- a/timed/models.py +++ b/timed/models.py @@ -9,8 +9,6 @@ class WeekdaysField(MultiSelectField): Stores weekdays as comma-separated values in database as iso week day (MON = 1, SUN = 7). - For django-jet to properly show multiple choices in admin - it is necessary to overwrite checkbox_option.html template. """ MO, TU, WE, TH, FR, SA, SU = range(1, 8) diff --git a/timed/settings.py b/timed/settings.py index 83913c93c..020abcd9d 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -30,7 +30,6 @@ # Application definition INSTALLED_APPS = [ - 'jet', 'django.contrib.admin', 'multiselectfield', 'django.forms', @@ -40,7 +39,6 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', - 'crispy_forms', 'timed.employment', 'timed.projects', 'timed.tracking', diff --git a/timed/templates/django/forms/widgets/checkbox_option.html b/timed/templates/django/forms/widgets/checkbox_option.html deleted file mode 100644 index cedbc88eb..000000000 --- a/timed/templates/django/forms/widgets/checkbox_option.html +++ /dev/null @@ -1,2 +0,0 @@ -{% include "django/forms/widgets/input.html" %} -{% if wrap_label %}{{ widget.label }}{% endif %} diff --git a/timed/urls.py b/timed/urls.py index 63ad7188f..cb15be2d9 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -5,7 +5,6 @@ from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token urlpatterns = [ - url(r'^jet/', include('jet.urls', 'jet')), url(r'^admin/', admin.site.urls), url(r'^api/v1/auth/login', obtain_jwt_token, name='login'), url(r'^api/v1/auth/refresh', refresh_jwt_token, name='refresh'), From b703ec6c254ed26a71dd633395b60e1946532a2c Mon Sep 17 00:00:00 2001 From: Christian Zosel Date: Mon, 24 Jul 2017 16:40:14 +0200 Subject: [PATCH 093/980] Fix worktime balance for users without active employment ... and add ordering on reports --- timed/employment/serializers.py | 9 +++++++-- timed/employment/tests/test_user.py | 19 +++++++++++++++++++ timed/tracking/tests/test_report.py | 1 + timed/tracking/views.py | 1 + 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 5fde8e7f6..3d0061f63 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -45,10 +45,15 @@ def get_worktime_balance_raw(self, instance): :returns: The worktime balance of the user :rtype: datetime.timedelta """ - employment = models.Employment.objects.get( + employment = models.Employment.objects.filter( user=instance, end_date__isnull=True - ) + ).first() + + # If there is no active employment, set the balance to 0 + if employment is None: + return timedelta() + location = employment.location request = self.context.get('request') diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 8372676ec..9bbe01f93 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -2,6 +2,7 @@ from datetime import date, timedelta +from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.utils.duration import duration_string from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, @@ -193,3 +194,21 @@ def test_user_worktime_balance(self): result2['data']['attributes']['worktime-balance'] == duration_string(timedelta(hours=28) - expected_worktime) ) + + def test_user_without_employment(self): + user = User.objects.create_user(username='test', password='1234qwer') + self.client.login('test', '1234qwer') + + url = reverse('user-list') + + res = self.client.get(url) + + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + assert len(result['data']) == 1 + assert int(result['data'][0]['id']) == user.id + assert result['data'][0]['attributes']['worktime-balance'] == ( + '00:00:00' + ) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index f0273640b..ce253cab3 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -49,6 +49,7 @@ def test_report_list(self): 'task': self.reports[0].task.id, 'project': self.reports[0].task.project.id, 'customer': self.reports[0].task.project.customer.id, + 'include': 'user,task,task.project,task.project.customer' }) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED diff --git a/timed/tracking/views.py b/timed/tracking/views.py index ee8866d02..4d8fd16d1 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -77,6 +77,7 @@ class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet permission_classes = [IsAuthenticated, permissions.IsOwnerOrReadOnly] + ordering = ('id', ) @list_route() def export(self, request): From b04fee6c37073f499c648efea4f118c1c8a08b8c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 24 Jul 2017 17:35:14 +0200 Subject: [PATCH 094/980] For re-usability of timed modules defining deps in setuptools --- Makefile | 6 +++--- requirements.txt | 14 -------------- setup.py | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 17 deletions(-) delete mode 100644 requirements.txt diff --git a/Makefile b/Makefile index 8d46008c7..b51df6bf7 100644 --- a/Makefile +++ b/Makefile @@ -7,12 +7,12 @@ help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' install: ## Install production environment - @pip install --upgrade -r requirements.txt + @pip install --upgrade pip @pip install --upgrade . install-dev: ## Install development environment - @pip install --upgrade -r requirements.txt -r dev_requirements.txt - @pip install -e . + @pip install --upgrade pip + @pip install --upgrade -r dev_requirements.txt -e . setup-ldap: ## Setup the LDAP container docker exec -it $(UCS_CONTAINER_ID) /usr/lib/univention-system-setup/scripts/setup-join.sh diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0a2d18554..000000000 --- a/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -django>=1.11,<1.12 -django-auth-ldap==1.2.11 -django-filter==1.0.2 -django-multiselectfield==0.1.6 -djangorestframework>=3.6,<3.7 -djangorestframework-jsonapi==2.2.0 -djangorestframework-jwt==1.10.0 -psycopg2>=2.7,<2.8 -pytz==2017.2 -pyexcel-webio==0.1.2 -pyexcel-io==0.4.2 -django-excel==0.0.9 -pyexcel-ods3==0.4.0 -pyexcel-xlsx==0.4.1 diff --git a/setup.py b/setup.py index 09c7aa6da..8bd03d2b9 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,22 @@ author_email='https://adfinis-sygroup.ch/', description='Timetracking software', long_description=README_TEXT, + install_requires=( + 'django>=1.11,<1.12', + 'django-auth-ldap==1.2.11', + 'django-filter==1.0.2', + 'django-multiselectfield==0.1.6', + 'djangorestframework>=3.6,<3.7', + 'djangorestframework-jsonapi==2.2.0', + 'djangorestframework-jwt==1.10.0', + 'psycopg2>=2.7,<2.8', + 'pytz==2017.2', + 'pyexcel-webio==0.1.2', + 'pyexcel-io==0.4.2', + 'django-excel==0.0.9', + 'pyexcel-ods3==0.4.0', + 'pyexcel-xlsx==0.4.1', + ), keywords='timetracking', url='https://adfinis-sygroup.ch/', packages=find_packages(), From cb15b76a1756be1ef786ec665ee445d2387cf5bc Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Jul 2017 09:06:20 +0200 Subject: [PATCH 095/980] Calling isort directly should behave same way using flake8 --- .isort.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.isort.cfg b/.isort.cfg index e69de29bb..273f0c599 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +skip=migrations From 433bac12a07aa6efe2a9c522de67ec65e27f6f00 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Jul 2017 11:37:07 +0200 Subject: [PATCH 096/980] Integrated timed subscription model --- timed/subscription/__init__.py | 0 timed/subscription/admin.py | 27 ++++++++++ timed/subscription/migrations/0001_initial.py | 50 +++++++++++++++++++ timed/subscription/migrations/__init__.py | 0 timed/subscription/models.py | 40 +++++++++++++++ timed/subscription/tests/__init__.py | 0 6 files changed, 117 insertions(+) create mode 100644 timed/subscription/__init__.py create mode 100644 timed/subscription/admin.py create mode 100644 timed/subscription/migrations/0001_initial.py create mode 100644 timed/subscription/migrations/__init__.py create mode 100644 timed/subscription/models.py create mode 100644 timed/subscription/tests/__init__.py diff --git a/timed/subscription/__init__.py b/timed/subscription/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py new file mode 100644 index 000000000..b46997a18 --- /dev/null +++ b/timed/subscription/admin.py @@ -0,0 +1,27 @@ +"""Views for the admin interface.""" + +from django.contrib import admin + +from . import models + + +@admin.register(models.Subscription) +class SubscriptionAdmin(admin.ModelAdmin): + """Subscription admin view.""" + + list_display = ['name', 'archived'] + + +@admin.register(models.Package) +class PackageAdmin(admin.ModelAdmin): + """Attendance admin view.""" + + list_display = ['subscription', 'duration', 'price'] + + +@admin.register(models.SubscriptionProject) +class SubscriptionProjectAdmin(admin.ModelAdmin): + """Subscription project assignment admin view.""" + + list_display = ['project', 'subscription'] + list_filter = ['project'] diff --git a/timed/subscription/migrations/0001_initial.py b/timed/subscription/migrations/0001_initial.py new file mode 100644 index 000000000..d3c3c02ad --- /dev/null +++ b/timed/subscription/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-16 14:51 +from __future__ import unicode_literals + +from decimal import Decimal +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Package', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('duration', models.DurationField()), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[('XUA', 'ADB Unit of Account'), ('AFN', 'Afghani'), ('DZD', 'Algerian Dinar'), ('ARS', 'Argentine Peso'), ('AMD', 'Armenian Dram'), ('AWG', 'Aruban Guilder'), ('AUD', 'Australian Dollar'), ('AZN', 'Azerbaijanian Manat'), ('BSD', 'Bahamian Dollar'), ('BHD', 'Bahraini Dinar'), ('THB', 'Baht'), ('PAB', 'Balboa'), ('BBD', 'Barbados Dollar'), ('BYN', 'Belarussian Ruble'), ('BYR', 'Belarussian Ruble'), ('BZD', 'Belize Dollar'), ('BMD', 'Bermudian Dollar (customarily known as Bermuda Dollar)'), ('BTN', 'Bhutanese ngultrum'), ('VEF', 'Bolivar Fuerte'), ('BOB', 'Boliviano'), ('XBA', 'Bond Markets Units European Composite Unit (EURCO)'), ('BRL', 'Brazilian Real'), ('BND', 'Brunei Dollar'), ('BGN', 'Bulgarian Lev'), ('BIF', 'Burundi Franc'), ('XOF', 'CFA Franc BCEAO'), ('XAF', 'CFA franc BEAC'), ('XPF', 'CFP Franc'), ('CAD', 'Canadian Dollar'), ('CVE', 'Cape Verde Escudo'), ('KYD', 'Cayman Islands Dollar'), ('CLP', 'Chilean peso'), ('XTS', 'Codes specifically reserved for testing purposes'), ('COP', 'Colombian peso'), ('KMF', 'Comoro Franc'), ('CDF', 'Congolese franc'), ('BAM', 'Convertible Marks'), ('NIO', 'Cordoba Oro'), ('CRC', 'Costa Rican Colon'), ('HRK', 'Croatian Kuna'), ('CUP', 'Cuban Peso'), ('CUC', 'Cuban convertible peso'), ('CZK', 'Czech Koruna'), ('GMD', 'Dalasi'), ('DKK', 'Danish Krone'), ('MKD', 'Denar'), ('DJF', 'Djibouti Franc'), ('STD', 'Dobra'), ('DOP', 'Dominican Peso'), ('VND', 'Dong'), ('XCD', 'East Caribbean Dollar'), ('EGP', 'Egyptian Pound'), ('SVC', 'El Salvador Colon'), ('ETB', 'Ethiopian Birr'), ('EUR', 'Euro'), ('XBB', 'European Monetary Unit (E.M.U.-6)'), ('XBD', 'European Unit of Account 17(E.U.A.-17)'), ('XBC', 'European Unit of Account 9(E.U.A.-9)'), ('FKP', 'Falkland Islands Pound'), ('FJD', 'Fiji Dollar'), ('HUF', 'Forint'), ('GHS', 'Ghana Cedi'), ('GIP', 'Gibraltar Pound'), ('XAU', 'Gold'), ('XFO', 'Gold-Franc'), ('PYG', 'Guarani'), ('GNF', 'Guinea Franc'), ('GYD', 'Guyana Dollar'), ('HTG', 'Haitian gourde'), ('HKD', 'Hong Kong Dollar'), ('UAH', 'Hryvnia'), ('ISK', 'Iceland Krona'), ('INR', 'Indian Rupee'), ('IRR', 'Iranian Rial'), ('IQD', 'Iraqi Dinar'), ('IMP', 'Isle of Man Pound'), ('JMD', 'Jamaican Dollar'), ('JOD', 'Jordanian Dinar'), ('KES', 'Kenyan Shilling'), ('PGK', 'Kina'), ('LAK', 'Kip'), ('KWD', 'Kuwaiti Dinar'), ('AOA', 'Kwanza'), ('MMK', 'Kyat'), ('GEL', 'Lari'), ('LVL', 'Latvian Lats'), ('LBP', 'Lebanese Pound'), ('ALL', 'Lek'), ('HNL', 'Lempira'), ('SLL', 'Leone'), ('LSL', 'Lesotho loti'), ('LRD', 'Liberian Dollar'), ('LYD', 'Libyan Dinar'), ('SZL', 'Lilangeni'), ('LTL', 'Lithuanian Litas'), ('MGA', 'Malagasy Ariary'), ('MWK', 'Malawian Kwacha'), ('MYR', 'Malaysian Ringgit'), ('TMM', 'Manat'), ('MUR', 'Mauritius Rupee'), ('MZN', 'Metical'), ('MXV', 'Mexican Unidad de Inversion (UDI)'), ('MXN', 'Mexican peso'), ('MDL', 'Moldovan Leu'), ('MAD', 'Moroccan Dirham'), ('BOV', 'Mvdol'), ('NGN', 'Naira'), ('ERN', 'Nakfa'), ('NAD', 'Namibian Dollar'), ('NPR', 'Nepalese Rupee'), ('ANG', 'Netherlands Antillian Guilder'), ('ILS', 'New Israeli Sheqel'), ('RON', 'New Leu'), ('TWD', 'New Taiwan Dollar'), ('NZD', 'New Zealand Dollar'), ('KPW', 'North Korean Won'), ('NOK', 'Norwegian Krone'), ('PEN', 'Nuevo Sol'), ('MRO', 'Ouguiya'), ('TOP', 'Paanga'), ('PKR', 'Pakistan Rupee'), ('XPD', 'Palladium'), ('MOP', 'Pataca'), ('PHP', 'Philippine Peso'), ('XPT', 'Platinum'), ('GBP', 'Pound Sterling'), ('BWP', 'Pula'), ('QAR', 'Qatari Rial'), ('GTQ', 'Quetzal'), ('ZAR', 'Rand'), ('OMR', 'Rial Omani'), ('KHR', 'Riel'), ('MVR', 'Rufiyaa'), ('IDR', 'Rupiah'), ('RUB', 'Russian Ruble'), ('RWF', 'Rwanda Franc'), ('XDR', 'SDR'), ('SHP', 'Saint Helena Pound'), ('SAR', 'Saudi Riyal'), ('RSD', 'Serbian Dinar'), ('SCR', 'Seychelles Rupee'), ('XAG', 'Silver'), ('SGD', 'Singapore Dollar'), ('SBD', 'Solomon Islands Dollar'), ('KGS', 'Som'), ('SOS', 'Somali Shilling'), ('TJS', 'Somoni'), ('SSP', 'South Sudanese Pound'), ('LKR', 'Sri Lanka Rupee'), ('XSU', 'Sucre'), ('SDG', 'Sudanese Pound'), ('SRD', 'Surinam Dollar'), ('SEK', 'Swedish Krona'), ('CHF', 'Swiss Franc'), ('SYP', 'Syrian Pound'), ('BDT', 'Taka'), ('WST', 'Tala'), ('TZS', 'Tanzanian Shilling'), ('KZT', 'Tenge'), ('XXX', 'The codes assigned for transactions where no currency is involved'), ('TTD', 'Trinidad and Tobago Dollar'), ('MNT', 'Tugrik'), ('TND', 'Tunisian Dinar'), ('TRY', 'Turkish Lira'), ('TMT', 'Turkmenistan New Manat'), ('TVD', 'Tuvalu dollar'), ('AED', 'UAE Dirham'), ('XFU', 'UIC-Franc'), ('USD', 'US Dollar'), ('USN', 'US Dollar (Next day)'), ('UGX', 'Uganda Shilling'), ('CLF', 'Unidad de Fomento'), ('COU', 'Unidad de Valor Real'), ('UYI', 'Uruguay Peso en Unidades Indexadas (URUIURUI)'), ('UYU', 'Uruguayan peso'), ('UZS', 'Uzbekistan Sum'), ('VUV', 'Vatu'), ('CHE', 'WIR Euro'), ('CHW', 'WIR Franc'), ('KRW', 'Won'), ('YER', 'Yemeni Rial'), ('JPY', 'Yen'), ('CNY', 'Yuan Renminbi'), ('ZMK', 'Zambian Kwacha'), ('ZMW', 'Zambian Kwacha'), ('ZWD', 'Zimbabwe Dollar A/06'), ('ZWN', 'Zimbabwe dollar A/08'), ('ZWL', 'Zimbabwe dollar A/09'), ('PLN', 'Zloty')], default='CHF', editable=False, max_length=3)), + ('price', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), default_currency='CHF', max_digits=7)), + ], + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('archived', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='SubscriptionProject', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='projects.Project')), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='timed_subscription.Subscription')), + ], + ), + migrations.AddField( + model_name='package', + name='subscription', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='timed_subscription.Subscription'), + ), + ] diff --git a/timed/subscription/migrations/__init__.py b/timed/subscription/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/subscription/models.py b/timed/subscription/models.py new file mode 100644 index 000000000..dfff9cd23 --- /dev/null +++ b/timed/subscription/models.py @@ -0,0 +1,40 @@ +from __future__ import unicode_literals + +from django.db import models +from djmoney.models.fields import MoneyField + + +class Subscription(models.Model): + """Representation of a support subscription.""" + + name = models.CharField(max_length=255) + archived = models.BooleanField(default=False) + + def __str__(self): + """Represent the model as a string. + + :return: The string representation + :rtype: str + """ + return self.name + + +class Package(models.Model): + """Representing a subscription package.""" + + subscription = models.ForeignKey(Subscription) + duration = models.DurationField() + price = MoneyField(max_digits=7, decimal_places=2, + default_currency='CHF') + + +class SubscriptionProject(models.Model): + """ + Assign subscription to project. + + A project can only be assigned to one subscription. + """ + + project = models.OneToOneField('projects.Project', + related_name='subscription') + subscription = models.ForeignKey(Subscription) diff --git a/timed/subscription/tests/__init__.py b/timed/subscription/tests/__init__.py new file mode 100644 index 000000000..e69de29bb From ebd2b5cc323351943f9f9c7ed3c1fd56d6c3d9f9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Jul 2017 14:17:26 +0200 Subject: [PATCH 097/980] Added missing python dateutil dependency --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8bd03d2b9..4a2420165 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ description='Timetracking software', long_description=README_TEXT, install_requires=( + 'python-dateutil>=2.6,<2.7', 'django>=1.11,<1.12', 'django-auth-ldap==1.2.11', 'django-filter==1.0.2', From 39748129b171d17dc207798da7d27f97fa2faee7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Jul 2017 14:38:39 +0200 Subject: [PATCH 098/980] Integrated timescout app --- timed/subscription/migrations/0001_initial.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/timed/subscription/migrations/0001_initial.py b/timed/subscription/migrations/0001_initial.py index d3c3c02ad..e89233cbb 100644 --- a/timed/subscription/migrations/0001_initial.py +++ b/timed/subscription/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-05-16 14:51 +# Generated by Django 1.11.3 on 2017-07-25 12:29 from __future__ import unicode_literals from decimal import Decimal @@ -13,7 +13,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('projects', '0001_initial'), + ('projects', '0002_auto_20170519_1537'), ] operations = [ @@ -39,12 +39,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='projects.Project')), - ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='timed_subscription.Subscription')), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='subscription.Subscription')), ], ), migrations.AddField( model_name='package', name='subscription', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='timed_subscription.Subscription'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='subscription.Subscription'), ), ] From c5336745c5d8322c4dc9576ded31093c84c91cbd Mon Sep 17 00:00:00 2001 From: Christian Zosel Date: Wed, 26 Jul 2017 10:39:47 +0200 Subject: [PATCH 099/980] Open up user endpoint, add django_filters, throw 400 --- timed/employment/views.py | 2 -- timed/settings.py | 1 + timed/tracking/views.py | 7 ++----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/timed/employment/views.py b/timed/employment/views.py index af0d9032a..4d791cf68 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -22,8 +22,6 @@ def get_queryset(self): return get_user_model().objects.prefetch_related( 'employments', 'absence_credits' - ).filter( - pk=self.request.user.pk ) diff --git a/timed/settings.py b/timed/settings.py index 020abcd9d..c303a6fb0 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -39,6 +39,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'django_filters', 'timed.employment', 'timed.projects', 'timed.tracking', diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 4d8fd16d1..094ffa51b 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,8 +1,8 @@ """Viewsets for the tracking app.""" import django_excel +from django.http import HttpResponseBadRequest from rest_framework.decorators import list_route -from rest_framework.exceptions import ParseError from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet @@ -102,10 +102,7 @@ def export(self, request): file_type = request.query_params.get('file_type') if file_type not in ['csv', 'xlsx', 'ods']: - raise ParseError( - 'Invalid file_type parameter. ' - 'Only csv, xlsx and ods are supported.' - ) + return HttpResponseBadRequest() sheet = django_excel.pe.Sheet( content, name='Report', colnames=colnames From 1c853147f955bee07b46feb5161a43dbaa8a9838 Mon Sep 17 00:00:00 2001 From: Christian Zosel Date: Wed, 26 Jul 2017 11:00:26 +0200 Subject: [PATCH 100/980] Fix tests --- timed/employment/tests/test_user.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 9bbe01f93..b564fd4a9 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -6,7 +6,6 @@ from django.core.urlresolvers import reverse from django.utils.duration import duration_string from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_404_NOT_FOUND, HTTP_405_METHOD_NOT_ALLOWED) from timed.employment.factories import (EmploymentFactory, @@ -32,7 +31,7 @@ def setUp(self): EmploymentFactory.create(user=user) def test_user_list(self): - """Should respond with a list of one user: the currently logged in.""" + """Should respond with a list of all users.""" url = reverse('user-list') noauth_res = self.noauth_client.get(url) @@ -43,7 +42,7 @@ def test_user_list(self): result = self.result(res) - assert len(result['data']) == 1 + assert len(result['data']) == 4 assert int(result['data'][0]['id']) == self.user.id def test_logged_in_user_detail(self): @@ -62,7 +61,7 @@ def test_logged_in_user_detail(self): assert res.status_code == HTTP_200_OK def test_not_logged_in_user_detail(self): - """Should throw a 404 since we don't request the logged in user.""" + """Should return other users too.""" url = reverse('user-detail', args=[ self.users[0].id ]) @@ -71,7 +70,7 @@ def test_not_logged_in_user_detail(self): res = self.client.get(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_404_NOT_FOUND + assert res.status_code == HTTP_200_OK def test_user_create(self): """Should not be able to create a new user.""" @@ -199,16 +198,17 @@ def test_user_without_employment(self): user = User.objects.create_user(username='test', password='1234qwer') self.client.login('test', '1234qwer') - url = reverse('user-list') + url = reverse('user-detail', args=[ + user.id + ]) - res = self.client.get(url) + res = self.client.get(url) assert res.status_code == HTTP_200_OK result = self.result(res) - assert len(result['data']) == 1 - assert int(result['data'][0]['id']) == user.id - assert result['data'][0]['attributes']['worktime-balance'] == ( + assert int(result['data']['id']) == user.id + assert result['data']['attributes']['worktime-balance'] == ( '00:00:00' ) From e055084d10a87bfa0651d5716e7966ce013e3969 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 6 Jul 2017 11:20:43 +0200 Subject: [PATCH 101/980] Added my most frequent to task end point It only counts most frequent tasks of user which have been reported to in the last few months as only those are relevant for usability. --- timed/projects/filters.py | 33 +++++++++++++++++++++++++++++-- timed/projects/tests/test_task.py | 31 +++++++++++++++++++++++++++++ timed/projects/views.py | 9 +++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 9a571c364..e1c6a8e82 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -1,6 +1,8 @@ """Filters for filtering the data of the projects app endpoints.""" +from datetime import date, timedelta -from django_filters import FilterSet +from django.db.models import Count +from django_filters import Filter, FilterSet from timed.projects import models @@ -25,11 +27,38 @@ class Meta: fields = ['archived', 'customer'] +class MyMostFrequentTaskFilter(Filter): + """Filter most frequently used tasks.""" + + def filter(self, qs, value): + """Filter for given most frequently used tasks. + + Most frequently used tasks are only counted within last + few months as older tasks are not relevant anymore + for today's usage. + + :param QuerySet qs: The queryset to filter + :param int value: number of frequest items + :return: The filtered queryset + :rtype: QuerySet + """ + user = self.parent.request.user + from_date = date.today() - timedelta(days=60) + + qs = qs.filter(reports__user=user, reports__date__gt=from_date) + qs = qs.annotate(frequency=Count('reports')).order_by('-frequency') + qs = qs[:int(value)] + + return qs + + class TaskFilterSet(FilterSet): """Filter set for the tasks endpoint.""" + my_most_frequent = MyMostFrequentTaskFilter() + class Meta: """Meta information for the task filter set.""" model = models.Task - fields = ['archived', 'project'] + fields = ['archived', 'project', 'my_most_frequent'] diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 36a81eb4f..e9eef1a58 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -1,4 +1,5 @@ """Tests for the tasks endpoint.""" +from datetime import date, timedelta from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, @@ -6,6 +7,7 @@ from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import TaskFactory +from timed.tracking.factories import ReportFactory class TaskTests(JSONAPITestCase): @@ -40,6 +42,35 @@ def test_task_list(self): assert 'name' in result['data'][0]['attributes'] assert 'project' in result['data'][0]['relationships'] + def test_task_my_most_frequent(self): + """Should respond with a list of my most frequent tasks.""" + report_date = date.today() - timedelta(days=20) + old_report_date = date.today() - timedelta(days=90) + + # tasks[0] should appear as most frequently used task + ReportFactory.create_batch( + 5, date=report_date, user=self.user, task=self.tasks[0] + ) + # tasks[1] should appear as secondly most frequently used task + ReportFactory.create_batch( + 4, date=report_date, user=self.user, task=self.tasks[1] + ) + # tasks[2] should not appear in result, as too far in the past + ReportFactory.create_batch( + 4, date=old_report_date, user=self.user, task=self.tasks[2] + ) + + url = reverse('task-list') + + res = self.client.get(url, {'my_most_frequent': '10'}) + assert res.status_code == HTTP_200_OK + + result = self.result(res) + data = result['data'] + assert len(data) == 2 + assert data[0]['id'] == str(self.tasks[0].id) + assert data[1]['id'] == str(self.tasks[1].id) + def test_task_detail(self): """Should respond with a single task.""" task = self.tasks[0] diff --git a/timed/projects/views.py b/timed/projects/views.py index 6cec4178b..403a51f5e 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -63,3 +63,12 @@ def get_queryset(self): ).filter( archived=False ) + + def filter_queryset(self, queryset): + """Specific filter queryset options.""" + # my most frequent filter uses LIMIT so default ordering + # needs to be disabled to avoid exception + if 'my_most_frequent' in self.request.query_params: + self.ordering = None + + return super().filter_queryset(queryset) From 3796082dd09f4c94113b86983a881489bcb9cc6e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 6 Jul 2017 11:41:50 +0200 Subject: [PATCH 102/980] Added TODOs for clarification of my most frequent task filter --- timed/projects/filters.py | 8 +++++++- timed/projects/views.py | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index e1c6a8e82..7675fa563 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -28,7 +28,13 @@ class Meta: class MyMostFrequentTaskFilter(Filter): - """Filter most frequently used tasks.""" + """Filter most frequently used tasks. + + TODO: + From an api and framework standpoint instead of an additional filter it + would be more desirable to assign an ordering field frecency and to + limit by use paging. This is way harder to implement therefore on hold. + """ def filter(self, qs, value): """Filter for given most frequently used tasks. diff --git a/timed/projects/views.py b/timed/projects/views.py index 403a51f5e..5195901b7 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -68,6 +68,7 @@ def filter_queryset(self, queryset): """Specific filter queryset options.""" # my most frequent filter uses LIMIT so default ordering # needs to be disabled to avoid exception + # see TODO filters.MyMostFrequentTaskFilter to avoid this if 'my_most_frequent' in self.request.query_params: self.ordering = None From 10ce4435f713f41c27edb9be9b0c549d11938baf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 26 Jul 2017 16:11:02 +0200 Subject: [PATCH 103/980] Clarification on limit syntax --- timed/projects/filters.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 7675fa563..63bdf1713 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -44,7 +44,7 @@ def filter(self, qs, value): for today's usage. :param QuerySet qs: The queryset to filter - :param int value: number of frequest items + :param int value: number of most frequent items :return: The filtered queryset :rtype: QuerySet """ @@ -53,6 +53,7 @@ def filter(self, qs, value): qs = qs.filter(reports__user=user, reports__date__gt=from_date) qs = qs.annotate(frequency=Count('reports')).order_by('-frequency') + # limit number of results to given value qs = qs[:int(value)] return qs From 74b5fb800a2e159e7c59c46e80d5b25937a5bd00 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 27 Jul 2017 09:51:18 +0200 Subject: [PATCH 104/980] explicitly define timed as first party --- .isort.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/.isort.cfg b/.isort.cfg index 273f0c599..94466a960 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,3 @@ [settings] skip=migrations +known_first_party=timed From 586ce51b1dbdddc471f21b280053e8e46c4959c5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 27 Jul 2017 09:52:16 +0200 Subject: [PATCH 105/980] Tracking activities may only be changed in frontend --- timed/tracking/admin.py | 63 ----------------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 timed/tracking/admin.py diff --git a/timed/tracking/admin.py b/timed/tracking/admin.py deleted file mode 100644 index f889a2d88..000000000 --- a/timed/tracking/admin.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Views for the admin interface.""" - -from django.contrib import admin - -from timed.tracking import models - - -class OwnerAdminMixin(object): - """Mixin for filtering an admin view by the user of the request.""" - - owner_field = 'user' - - def get_queryset(self, request): - """Filter a queryset by the user of the request. - - :param django.http.Request request: The HTTP request for this view - :return: The filtered queryset - :rtype: QuerySet - """ - qs = super().get_queryset(request) - - if request.user.is_superuser: - return qs - - return qs.filter(**{self.owner_field: request.user}) - - -class ActivityBlockInline(OwnerAdminMixin, admin.StackedInline): - """Activity block inline admin.""" - - model = models.ActivityBlock - owner_field = 'activity__user' - - -@admin.register(models.Activity) -class ActivityAdmin(OwnerAdminMixin, admin.ModelAdmin): - """Activity admin view.""" - - list_display = ['comment', 'task', 'user', 'duration'] - list_filter = ['user', 'task', 'task__project', 'task__project__customer'] - inlines = (ActivityBlockInline,) - - -@admin.register(models.Attendance) -class AttendanceAdmin(OwnerAdminMixin, admin.ModelAdmin): - """Attendance admin view.""" - - list_display = ['user', 'from_datetime', 'to_datetime'] - list_filter = ['user'] - - -@admin.register(models.Report) -class ReportAdmin(OwnerAdminMixin, admin.ModelAdmin): - """Report admin view.""" - - list_display = ['user', 'task', 'date', 'duration', 'comment'] - - -@admin.register(models.Absence) -class AbsenceAdmin(OwnerAdminMixin, admin.ModelAdmin): - """Absence admin view.""" - - list_display = ['user', 'date', 'duration', 'type'] From ce0e0e83d275152994191597980adc115857b22c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 27 Jul 2017 11:57:27 +0200 Subject: [PATCH 106/980] Allow staff member to update reports All fields may be changed by staff but date, user and duration. --- timed/employment/serializers.py | 1 + timed/jsonapi_test_case.py | 3 +- timed/tracking/permissions.py | 11 +++-- timed/tracking/serializers.py | 20 ++++++++ timed/tracking/tests/test_report.py | 71 +++++++++++++++++++++++++++-- timed/tracking/views.py | 5 +- 6 files changed, 100 insertions(+), 11 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 3d0061f63..0e6d31fb5 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -153,6 +153,7 @@ class Meta: 'employments', 'absence_credits', 'worktime_balance', + 'is_staff', ] diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index cffce514a..58b1e078c 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -111,7 +111,8 @@ def setUp(self): self.user = User.objects.create_user( username='user', - password='123qweasd' + password='123qweasd', + is_staff=True, ) self.client = JSONAPIClient() diff --git a/timed/tracking/permissions.py b/timed/tracking/permissions.py index 3bc3597ab..62a9e22a6 100644 --- a/timed/tracking/permissions.py +++ b/timed/tracking/permissions.py @@ -1,11 +1,16 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission -class IsOwnerOrReadOnly(BasePermission): - """Changing an object is only allowed if object belongs to current user.""" +class IsOwnerOrStaffElseReadOnly(BasePermission): + """ + Restrict writing to object for owner or staff only. + + Changing an object is only allowed if object belongs to current user + or user is a staff member. + """ def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True - return obj.user_id == request.user.id + return obj.user_id == request.user.id or request.user.is_staff diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index bfb9ad1ba..0782ca075 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -121,6 +121,26 @@ class ReportSerializer(ModelSerializer): 'user': 'timed.employment.serializers.UserSerializer' } + def validate_date(self, value): + """Only owner is allowed to change date.""" + if self.instance is not None: + user = self.context['request'].user + owner = self.instance.user + if self.instance.date != value and user != owner: + raise ValidationError('Only owner may change date') + + return value + + def validate_duration(self, value): + """Only owner is allowed to change duration.""" + if self.instance is not None: + user = self.context['request'].user + owner = self.instance.user + if self.instance.duration != value and user != owner: + raise ValidationError('Only owner may change duration') + + return value + class Meta: """Meta information for the report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index ce253cab3..0022669b7 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -128,7 +128,7 @@ def test_report_create(self): int(data['data']['relationships']['task']['data']['id']) ) - def test_report_update(self): + def test_report_update_owner(self): """Should update an existing report.""" report = self.reports[0] @@ -186,9 +186,70 @@ def test_report_update(self): int(data['data']['relationships']['task']['data']['id']) ) - def test_report_update_invalid_user(self): - """Updating of report belonging to different user is not allowed.""" + def test_report_update_date_staff(self): report = self.other_reports[0] + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'date': '2017-02-04' + }, + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + res = self.client.patch(url, data) + assert res.status_code == HTTP_400_BAD_REQUEST + + def test_report_update_duration_staff(self): + report = self.other_reports[0] + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'duration': '01:00:00', + }, + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + res = self.client.patch(url, data) + assert res.status_code == HTTP_400_BAD_REQUEST + + def test_report_update_not_staff_user(self): + """Updating of report belonging to different user is not allowed.""" + report = self.reports[0] + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + client = JSONAPIClient() + client.login('test', '123qweasd') + res = client.patch(url, data) + assert res.status_code == HTTP_403_FORBIDDEN + + def test_report_update_staff_user(self): + report = self.reports[0] data = { 'data': { 'type': 'reports', @@ -203,8 +264,8 @@ def test_report_update_invalid_user(self): report.id ]) - user_res = self.client.patch(url, data) - assert user_res.status_code == HTTP_403_FORBIDDEN + res = self.client.patch(url, data) + assert res.status_code == HTTP_200_OK def test_report_delete(self): """Should delete a report.""" diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 094ffa51b..d753d62cd 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -6,7 +6,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet -from timed.tracking import filters, models, permissions, serializers +from timed.tracking import filters, models, serializers +from timed.tracking.permissions import IsOwnerOrStaffElseReadOnly class ActivityViewSet(ModelViewSet): @@ -76,7 +77,7 @@ class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet - permission_classes = [IsAuthenticated, permissions.IsOwnerOrReadOnly] + permission_classes = [IsAuthenticated, IsOwnerOrStaffElseReadOnly] ordering = ('id', ) @list_route() From 25723a1a7be4ca49752a3196adb10446cd69674c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 27 Jul 2017 13:27:17 +0200 Subject: [PATCH 107/980] Add timed specific user model --- timed/employment/factories.py | 7 +-- timed/employment/migrations/0001_initial.py | 45 +++++++++++++++++-- .../migrations/0002_auto_20170512_1317.py | 26 ----------- .../migrations/0003_auto_20170519_1537.py | 23 ---------- timed/employment/models.py | 7 +++ timed/employment/tests/test_user.py | 5 ++- timed/jsonapi_test_case.py | 4 +- timed/projects/migrations/0001_initial.py | 14 +++++- .../migrations/0002_auto_20170519_1537.py | 27 ----------- timed/settings.py | 2 + timed/tracking/migrations/0001_initial.py | 15 +++++-- .../migrations/0002_absence_comment.py | 20 --------- .../migrations/0003_auto_20170518_1059.py | 23 ---------- .../migrations/0004_auto_20170616_1622.py | 20 --------- timed/tracking/tests/test_absence.py | 4 +- timed/tracking/tests/test_activity.py | 4 +- timed/tracking/tests/test_activity_block.py | 4 +- timed/tracking/tests/test_attendance.py | 4 +- timed/tracking/tests/test_report.py | 7 +-- 19 files changed, 97 insertions(+), 164 deletions(-) delete mode 100644 timed/employment/migrations/0002_auto_20170512_1317.py delete mode 100644 timed/employment/migrations/0003_auto_20170519_1537.py delete mode 100644 timed/projects/migrations/0002_auto_20170519_1537.py delete mode 100644 timed/tracking/migrations/0002_absence_comment.py delete mode 100644 timed/tracking/migrations/0003_auto_20170518_1059.py delete mode 100644 timed/tracking/migrations/0004_auto_20170616_1622.py diff --git a/timed/employment/factories.py b/timed/employment/factories.py index 67104051d..e5e8370ae 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -3,7 +3,7 @@ import datetime import random -from django.conf import settings +from django.contrib.auth import get_user_model from factory import Faker, SubFactory, lazy_attribute from factory.django import DjangoModelFactory @@ -33,13 +33,14 @@ def username(self): class Meta: """Meta informations for the user factory.""" - model = settings.AUTH_USER_MODEL + model = get_user_model() class LocationFactory(DjangoModelFactory): """Location factory.""" - name = Faker('city') + name = Faker('uuid4') + # cannot use city provider as name needs to be unique class Meta: """Meta informations for the location factory.""" diff --git a/timed/employment/migrations/0001_initial.py b/timed/employment/migrations/0001_initial.py index 407e14a50..8c662cd49 100644 --- a/timed/employment/migrations/0001_initial.py +++ b/timed/employment/migrations/0001_initial.py @@ -1,11 +1,15 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-11 14:23 +# Generated by Django 1.11.3 on 2017-07-27 11:18 from __future__ import unicode_literals from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators import django.core.validators from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone +import timed.models class Migration(migrations.Migration): @@ -13,10 +17,36 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('auth', '0008_alter_user_username_max_length'), ] operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name_plural': 'users', + 'verbose_name': 'user', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), migrations.CreateModel( name='AbsenceCredit', fields=[ @@ -47,7 +77,8 @@ class Migration(migrations.Migration): name='Location', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), + ('name', models.CharField(max_length=50, unique=True)), + ('workdays', timed.models.WeekdaysField(choices=[(1, 'Monday'), (2, 'Tuesday'), (3, 'Wednesday'), (4, 'Thursday'), (5, 'Friday'), (6, 'Saturday'), (7, 'Sunday')], default=['1', '2', '3', '4', '5'], max_length=13)), ], ), migrations.CreateModel( @@ -88,4 +119,12 @@ class Migration(migrations.Migration): name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to=settings.AUTH_USER_MODEL), ), + migrations.AddIndex( + model_name='publicholiday', + index=models.Index(fields=['date'], name='employment__date_2d002c_idx'), + ), + migrations.AddIndex( + model_name='employment', + index=models.Index(fields=['start_date', 'end_date'], name='employment__start_d_74c274_idx'), + ), ] diff --git a/timed/employment/migrations/0002_auto_20170512_1317.py b/timed/employment/migrations/0002_auto_20170512_1317.py deleted file mode 100644 index 18087d3e6..000000000 --- a/timed/employment/migrations/0002_auto_20170512_1317.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-05-12 11:17 -from __future__ import unicode_literals - -from django.db import migrations, models -import timed.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='location', - name='workdays', - field=timed.models.WeekdaysField(choices=[(1, 'Monday'), (2, 'Tuesday'), (3, 'Wednesday'), (4, 'Thursday'), (5, 'Friday'), (6, 'Saturday'), (7, 'Sunday')], default=['1', '2', '3', '4', '5'], max_length=13), - ), - migrations.AlterField( - model_name='location', - name='name', - field=models.CharField(max_length=50, unique=True), - ), - ] diff --git a/timed/employment/migrations/0003_auto_20170519_1537.py b/timed/employment/migrations/0003_auto_20170519_1537.py deleted file mode 100644 index 911af3b88..000000000 --- a/timed/employment/migrations/0003_auto_20170519_1537.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-05-19 13:37 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0002_auto_20170512_1317'), - ] - - operations = [ - migrations.AddIndex( - model_name='employment', - index=models.Index(fields=['start_date', 'end_date'], name='employment__start_d_74c274_idx'), - ), - migrations.AddIndex( - model_name='publicholiday', - index=models.Index(fields=['date'], name='employment__date_2d002c_idx'), - ), - ] diff --git a/timed/employment/models.py b/timed/employment/models.py index ee9a11e50..499f6fb1b 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -3,6 +3,7 @@ import datetime from django.conf import settings +from django.contrib.auth.models import AbstractUser from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -51,6 +52,12 @@ def __str__(self): return self.name +class User(AbstractUser): + """Timed specific user.""" + + pass + + class Employment(models.Model): """Employment model. diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index b564fd4a9..18aaf05cb 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -2,7 +2,7 @@ from datetime import date, timedelta -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.utils.duration import duration_string from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, @@ -195,7 +195,8 @@ def test_user_worktime_balance(self): ) def test_user_without_employment(self): - user = User.objects.create_user(username='test', password='1234qwer') + user = get_user_model().objects.create_user(username='test', + password='1234qwer') self.client.login('test', '1234qwer') url = reverse('user-detail', args=[ diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 58b1e078c..f9bf4f453 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -3,7 +3,7 @@ import json import logging -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from rest_framework import status from rest_framework.test import APIClient, APITestCase @@ -109,7 +109,7 @@ def setUp(self): """Set the clients for testing up.""" super().setUp() - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username='user', password='123qweasd', is_staff=True, diff --git a/timed/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py index b74e1b83d..0ff9f66f5 100644 --- a/timed/projects/migrations/0001_initial.py +++ b/timed/projects/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-11 14:23 +# Generated by Django 1.11.3 on 2017-07-27 11:18 from __future__ import unicode_literals from django.db import migrations, models @@ -53,4 +53,16 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=255)), ], ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['name', 'archived'], name='projects_cu_name_e0e97a_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['name', 'archived'], name='projects_ta_name_dd9620_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['name', 'archived'], name='projects_pr_name_ac60a8_idx'), + ), ] diff --git a/timed/projects/migrations/0002_auto_20170519_1537.py b/timed/projects/migrations/0002_auto_20170519_1537.py deleted file mode 100644 index 3a8001c48..000000000 --- a/timed/projects/migrations/0002_auto_20170519_1537.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-05-19 13:37 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0001_initial'), - ] - - operations = [ - migrations.AddIndex( - model_name='task', - index=models.Index(fields=['name', 'archived'], name='projects_ta_name_dd9620_idx'), - ), - migrations.AddIndex( - model_name='customer', - index=models.Index(fields=['name', 'archived'], name='projects_cu_name_e0e97a_idx'), - ), - migrations.AddIndex( - model_name='project', - index=models.Index(fields=['name', 'archived'], name='projects_pr_name_ac60a8_idx'), - ), - ] diff --git a/timed/settings.py b/timed/settings.py index c303a6fb0..e464cb9ce 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -203,3 +203,5 @@ AUTH_LDAP_BIND_DN = 'uid=Administrator,cn=users,{0}'.format(LDAP_BASE) AUTH_LDAP_PASSWORD = 'univention' AUTH_LDAP_USER_DN_TEMPLATE = 'uid=%(user)s,cn=users,{0}'.format(LDAP_BASE) + +AUTH_USER_MODEL = 'employment.User' diff --git a/timed/tracking/migrations/0001_initial.py b/timed/tracking/migrations/0001_initial.py index 49c88b8f3..815153fbc 100644 --- a/timed/tracking/migrations/0001_initial.py +++ b/timed/tracking/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-11 14:23 +# Generated by Django 1.11.3 on 2017-07-27 11:18 from __future__ import unicode_literals import datetime @@ -13,8 +13,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('employment', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('employment', '0001_initial'), ('projects', '0001_initial'), ] @@ -23,6 +23,7 @@ class Migration(migrations.Migration): name='Absence', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.TextField(blank=True)), ('date', models.DateField()), ('duration', models.DurationField(default=datetime.timedelta(0))), ('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='employment.AbsenceType')), @@ -46,7 +47,7 @@ class Migration(migrations.Migration): name='ActivityBlock', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('from_datetime', models.DateTimeField(auto_now_add=True)), + ('from_datetime', models.DateTimeField()), ('to_datetime', models.DateTimeField(blank=True, null=True)), ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='tracking.Activity')), ], @@ -74,6 +75,14 @@ class Migration(migrations.Migration): ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL)), ], ), + migrations.AddIndex( + model_name='report', + index=models.Index(fields=['date'], name='tracking_re_date_8770c2_idx'), + ), + migrations.AddIndex( + model_name='activity', + index=models.Index(fields=['start_datetime'], name='tracking_ac_start_d_c43640_idx'), + ), migrations.AlterUniqueTogether( name='absence', unique_together=set([('date', 'user')]), diff --git a/timed/tracking/migrations/0002_absence_comment.py b/timed/tracking/migrations/0002_absence_comment.py deleted file mode 100644 index 70d0fc20f..000000000 --- a/timed/tracking/migrations/0002_absence_comment.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-05-12 13:11 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='absence', - name='comment', - field=models.TextField(blank=True), - ), - ] diff --git a/timed/tracking/migrations/0003_auto_20170518_1059.py b/timed/tracking/migrations/0003_auto_20170518_1059.py deleted file mode 100644 index 67ee2aade..000000000 --- a/timed/tracking/migrations/0003_auto_20170518_1059.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-05-18 08:59 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0002_absence_comment'), - ] - - operations = [ - migrations.AddIndex( - model_name='report', - index=models.Index(fields=['date'], name='tracking_re_date_8770c2_idx'), - ), - migrations.AddIndex( - model_name='activity', - index=models.Index(fields=['start_datetime'], name='tracking_ac_start_d_c43640_idx'), - ), - ] diff --git a/timed/tracking/migrations/0004_auto_20170616_1622.py b/timed/tracking/migrations/0004_auto_20170616_1622.py deleted file mode 100644 index a0aaa106d..000000000 --- a/timed/tracking/migrations/0004_auto_20170616_1622.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-06-16 14:22 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0003_auto_20170518_1059'), - ] - - operations = [ - migrations.AlterField( - model_name='activityblock', - name='from_datetime', - field=models.DateTimeField(), - ), - ] diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 1f88a30d2..4618b1781 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -2,7 +2,7 @@ import datetime -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, @@ -24,7 +24,7 @@ def setUp(self): user = self.user - other_user = User.objects.create_user( + other_user = get_user_model().objects.create_user( username='test', password='123qweasd' ) diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index b71eefc78..5dad2b896 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -2,7 +2,7 @@ from datetime import datetime -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from pytz import timezone from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, @@ -19,7 +19,7 @@ def setUp(self): """Set the environment for the tests up.""" super().setUp() - other_user = User.objects.create_user( + other_user = get_user_model().objects.create_user( username='test', password='123qweasd' ) diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py index 3228c47c0..688e71ec6 100644 --- a/timed/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -2,7 +2,7 @@ from datetime import datetime -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from pytz import timezone from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, @@ -20,7 +20,7 @@ def setUp(self): """Set the environment for the tests up.""" super().setUp() - other_user = User.objects.create_user( + other_user = get_user_model().objects.create_user( username='test', password='123qweasd' ) diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index 551edcb8e..db963b8f2 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -2,7 +2,7 @@ from datetime import timedelta -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) @@ -18,7 +18,7 @@ def setUp(self): """Set the environment for the tests up.""" super().setUp() - other_user = User.objects.create_user( + other_user = get_user_model().objects.create_user( username='test', password='123qweasd' ) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 0022669b7..ad0fd3113 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -3,7 +3,7 @@ from datetime import date, timedelta import pyexcel -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.utils.duration import duration_string from hypothesis import given, settings @@ -30,7 +30,7 @@ def setUp(self): """Set the environment for the tests up.""" super().setUp() - other_user = User.objects.create_user( + other_user = get_user_model().objects.create_user( username='test', password='123qweasd' ) @@ -343,7 +343,8 @@ class TestReportHypo(TestCase): ) @settings(timeout=5) def test_report_export(self, file_type, report): - User.objects.create_user(username='test', password='1234qwer') + get_user_model().objects.create_user(username='test', + password='1234qwer') client = JSONAPIClient() client.login('test', '1234qwer') url = reverse('report-export') From 4b10dd0aed6663e8973a1c68bd97b28875f07a5e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 31 Jul 2017 10:51:34 +0200 Subject: [PATCH 108/980] Add added and updated date timestamp to Report model. Those dates are needed to improve reporting of tracked time. Example send out a report of reports which have been added in the last few days. --- .../migrations/0002_auto_20170731_1047.py | 27 +++++++++++++++++++ timed/tracking/models.py | 2 ++ 2 files changed, 29 insertions(+) create mode 100644 timed/tracking/migrations/0002_auto_20170731_1047.py diff --git a/timed/tracking/migrations/0002_auto_20170731_1047.py b/timed/tracking/migrations/0002_auto_20170731_1047.py new file mode 100644 index 000000000..a26c47003 --- /dev/null +++ b/timed/tracking/migrations/0002_auto_20170731_1047.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-31 08:47 +from __future__ import unicode_literals + +from django.db import migrations, models +from django.utils import timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='report', + name='added', + field=models.DateTimeField(auto_now_add=True, default=timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='report', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 124301820..0cbade076 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -119,6 +119,8 @@ class Report(models.Model): related_name='reports') user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='reports') + added = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) def save(self, *args, **kwargs): """Save the report with some custom functionality. From b5b97c04b788129def1519d042b45c012a7e0136 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 31 Jul 2017 12:53:49 +0200 Subject: [PATCH 109/980] Updated subscription migrations to new timed --- timed/subscription/migrations/0001_initial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/subscription/migrations/0001_initial.py b/timed/subscription/migrations/0001_initial.py index e89233cbb..85b67278e 100644 --- a/timed/subscription/migrations/0001_initial.py +++ b/timed/subscription/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-25 12:29 +# Generated by Django 1.11.3 on 2017-07-31 10:51 from __future__ import unicode_literals from decimal import Decimal @@ -13,7 +13,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('projects', '0002_auto_20170519_1537'), + ('projects', '0001_initial'), ] operations = [ From 0e1991194c09e1202a988e76df6b96bd44e77308 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 31 Jul 2017 12:55:01 +0200 Subject: [PATCH 110/980] Add redmine project configuration --- timed/redmine/__init__.py | 0 timed/redmine/migrations/0001_initial.py | 26 ++++++++++++++++++++++++ timed/redmine/migrations/__init__.py | 0 timed/redmine/models.py | 18 ++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 timed/redmine/__init__.py create mode 100644 timed/redmine/migrations/0001_initial.py create mode 100644 timed/redmine/migrations/__init__.py create mode 100644 timed/redmine/models.py diff --git a/timed/redmine/__init__.py b/timed/redmine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/redmine/migrations/0001_initial.py b/timed/redmine/migrations/0001_initial.py new file mode 100644 index 000000000..cbb7aa0ac --- /dev/null +++ b/timed/redmine/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-31 10:53 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='RedmineProject', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('issue_id', models.PositiveIntegerField()), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='redmine_project', to='projects.Project')), + ], + ), + ] diff --git a/timed/redmine/migrations/__init__.py b/timed/redmine/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/redmine/models.py b/timed/redmine/models.py new file mode 100644 index 000000000..edbe9d195 --- /dev/null +++ b/timed/redmine/models.py @@ -0,0 +1,18 @@ +from django.db import models + +from timed.projects.models import Project + + +class RedmineProject(models.Model): + """ + Definition of a Redmine Project. + + Defines what Timed project belongs to what Redmine issue. + """ + + project = models.OneToOneField( + Project, + on_delete=models.CASCADE, + related_name='redmine_project' + ) + issue_id = models.PositiveIntegerField() From 9465c66c67bba27a78f8914589bc7b1b1db7e751 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 31 Jul 2017 17:55:51 +0200 Subject: [PATCH 111/980] Add command to update Redmine project issues --- .../management/commands/redmine_report.py | 75 +++++++++++++++++++ .../templates/redmine/weekly_report.txt | 13 ++++ timed/redmine/templatetags/__init__.py | 0 timed/redmine/templatetags/float_hours.py | 9 +++ timed/redmine/tests/__init__.py | 0 timed/redmine/tests/test_redmine_report.py | 39 ++++++++++ 6 files changed, 136 insertions(+) create mode 100644 timed/redmine/management/commands/redmine_report.py create mode 100644 timed/redmine/templates/redmine/weekly_report.txt create mode 100644 timed/redmine/templatetags/__init__.py create mode 100644 timed/redmine/templatetags/float_hours.py create mode 100644 timed/redmine/tests/__init__.py create mode 100644 timed/redmine/tests/test_redmine_report.py diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py new file mode 100644 index 000000000..15eb9c4e0 --- /dev/null +++ b/timed/redmine/management/commands/redmine_report.py @@ -0,0 +1,75 @@ +from datetime import timedelta + +import redminelib +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import Count, Sum +from django.template.loader import render_to_string +from django.utils import timezone + +from timed.projects.models import Project +from timed.tracking.models import Report + + +class Command(BaseCommand): + help = 'Update associated Redmine projects and send reports to watchers.' + + def add_arguments(self, parser): + parser.add_argument( + '--last-days', + dest='last_days', + default=7, + help='Build report of number of last days', + type=int + ) + + def handle(self, *args, **options): + redmine = redminelib.Redmine( + settings.REDMINE_URL, + key=settings.REDMINE_APIKEY, + requets={ + 'auth': ( + settings.REDMINE_HTACCESS_USER, + settings.REDMINE_HTACCESS_PASSWORD + ) + } + ) + + last_days = options['last_days'] + # today is excluded + end = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = end - timedelta(days=last_days) + + # get projects with reports in given last days + affected_projects = Project.objects.filter( + archived=False, + redmine_project__isnull=False, + tasks__reports__updated__range=[start, end] + ).annotate( + count_reports=Count('tasks__reports'), + ).filter(count_reports__gt=0).values('id') + # calculate total hours + projects = Project.objects.filter( + id__in=affected_projects + ).annotate(total_hours=Sum('tasks__reports__duration')) + + for project in projects: + total_hours = project.total_hours.total_seconds() / 3600 + issue = redmine.issue.get(project.redmine_project.issue_id) + reports = Report.objects.filter( + task__project=project, updated__range=[start, end] + ).order_by('date') + hours = reports.aggregate(hours=Sum('duration'))['hours'] + + issue.notes = render_to_string('redmine/weekly_report.txt', { + 'project': project, + 'hours': hours.total_seconds() / 3600, + 'last_days': last_days, + 'total_hours': total_hours, + 'reports': reports + }) + issue.custom_fields = [{ + 'id': settings.REDMINE_SPENTHOURS_FIELD, + 'value': total_hours + }] + issue.save() diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/timed/redmine/templates/redmine/weekly_report.txt new file mode 100644 index 000000000..031529222 --- /dev/null +++ b/timed/redmine/templates/redmine/weekly_report.txt @@ -0,0 +1,13 @@ +{% load float_hours %} +
+Customer: {{project.customer.name}}
+Project: {{project.name}}
+Hours in last {{last_days}} days: {{hours}}
+Total hours: {{total_hours}}
+
+
+Reported in last {{last_days}} days:
+{% for report in reports %}
+{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment}}
+{% endfor %}
+
diff --git a/timed/redmine/templatetags/__init__.py b/timed/redmine/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/redmine/templatetags/float_hours.py b/timed/redmine/templatetags/float_hours.py new file mode 100644 index 000000000..c6e22b047 --- /dev/null +++ b/timed/redmine/templatetags/float_hours.py @@ -0,0 +1,9 @@ +from django import template + +register = template.Library() + + +@register.filter +def float_hours(duration): + """Convert timedelta to floating hours.""" + return duration.total_seconds() / 3600 diff --git a/timed/redmine/tests/__init__.py b/timed/redmine/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py new file mode 100644 index 000000000..a4c56a26b --- /dev/null +++ b/timed/redmine/tests/test_redmine_report.py @@ -0,0 +1,39 @@ +import pytest +from django.core.management import call_command + +from timed.tracking.factories import ReportFactory +from timed_adfinis.redmine.models import RedmineProject + + +@pytest.mark.freeze_time +def test_redmine_report(db, freezer, mocker): + """ + Test redmine report. + + Simulate reports added on Friday 2017-07-28 and cronjob run on + Monday 2017-07-31. + """ + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch('redminelib.Redmine') + redmine_class.return_value = redmine_instance + + freezer.move_to('2017-07-28') + report = ReportFactory.create() + report_hours = report.duration.total_seconds() / 3600 + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + # report not attached to redmine + ReportFactory.create() + + freezer.move_to('2017-07-31') + call_command('redmine_report', options={'--last-days': '7'}) + + redmine_instance.issue.get.assert_called_once_with(1000) + assert issue.custom_fields == [{ + 'id': 6, + 'value': report_hours + }] + assert 'Total hours: {0}'.format(report_hours) in issue.notes + assert 'Hours in last 7 days: {0}'.format(report_hours) in issue.notes + issue.save.assert_called_once_with() From 57051d3e59a4664a536588c19ae0581cb92f1350 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 3 Aug 2017 12:58:54 +0200 Subject: [PATCH 112/980] Add RedmineProject as inline of ProjectAdmin --- timed/redmine/admin.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 timed/redmine/admin.py diff --git a/timed/redmine/admin.py b/timed/redmine/admin.py new file mode 100644 index 000000000..7b9e94e89 --- /dev/null +++ b/timed/redmine/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from timed.projects.admin import ProjectAdmin +from timed.projects.models import Project +from timed_adfinis.redmine.models import RedmineProject + +admin.site.unregister(Project) + + +class RedmineProjectInline(admin.StackedInline): + model = RedmineProject + + +@admin.register(Project) +class ProjectAdmin(ProjectAdmin): + """Adfinis specific project including Redmine issue configuration.""" + + inlines = ProjectAdmin.inlines + [RedmineProjectInline, ] From be6ae209c1046f5998e57034b7e25d3f990bb4f9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 27 Jul 2017 16:35:58 +0200 Subject: [PATCH 113/980] Add possibility to configure supervisors per user --- timed/employment/admin.py | 33 ++++++++++++++----- .../migrations/0002_user_supervisors.py | 21 ++++++++++++ timed/employment/models.py | 18 ++++++++-- 3 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 timed/employment/migrations/0002_user_supervisors.py diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 36deee289..33998d763 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -4,12 +4,37 @@ from django import forms from django.contrib import admin +from django.contrib.auth.admin import UserAdmin from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from timed.employment import models +class SupervisorInline(admin.TabularInline): + model = models.User.supervisors.through + extra = 0 + fk_name = 'from_user' + verbose_name = _('Supervisor') + verbose_name_plural = _('Supervisors') + + +@admin.register(models.User) +class UserAdmin(UserAdmin): + """Timed specific user admin.""" + + inlines = [SupervisorInline] + exclude = ('supervisors', ) + + +@admin.register(models.Location) +class LocationAdmin(admin.ModelAdmin): + """Location admin view.""" + + list_display = ['name'] + search_fields = ['name'] + + class EmploymentForm(forms.ModelForm): """Custom form for the employment admin.""" @@ -70,14 +95,6 @@ class Meta: model = models.Employment -@admin.register(models.Location) -class LocationAdmin(admin.ModelAdmin): - """Location admin view.""" - - list_display = ['name'] - search_fields = ['name'] - - @admin.register(models.Employment) class EmploymentAdmin(admin.ModelAdmin): """Employment admin view.""" diff --git a/timed/employment/migrations/0002_user_supervisors.py b/timed/employment/migrations/0002_user_supervisors.py new file mode 100644 index 000000000..eced1e2ec --- /dev/null +++ b/timed/employment/migrations/0002_user_supervisors.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-27 13:42 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='supervisors', + field=models.ManyToManyField(related_name='supervisees', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 499f6fb1b..c69f97059 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -3,7 +3,7 @@ import datetime from django.conf import settings -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractUser, UserManager from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -52,10 +52,24 @@ def __str__(self): return self.name +class UserManager(UserManager): + def all_supervisors(self): + objects = self.model.objects.annotate( + supervisees_count=models.Count('supervisees')) + return objects.filter(supervisees_count__gt=0) + + def all_supervisees(self): + objects = self.model.objects.annotate( + supervisors_count=models.Count('supervisors')) + return objects.filter(supervisors_count__gt=0) + + class User(AbstractUser): """Timed specific user.""" - pass + supervisors = models.ManyToManyField('self', symmetrical=False, + related_name='supervisees') + objects = UserManager() class Employment(models.Model): From 877a8fc9989644312b18c5eeeb6552f84350c182 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 3 Aug 2017 17:32:00 +0200 Subject: [PATCH 114/980] Add support subscriptions for parity with SSA portal These includes: * customer password * subscription and packages * orders * import from timescout --- timed/redmine/admin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/timed/redmine/admin.py b/timed/redmine/admin.py index 7b9e94e89..d70465b33 100644 --- a/timed/redmine/admin.py +++ b/timed/redmine/admin.py @@ -3,6 +3,7 @@ from timed.projects.admin import ProjectAdmin from timed.projects.models import Project from timed_adfinis.redmine.models import RedmineProject +from timed_adfinis.subscription.admin import SubscriptionProjectInline admin.site.unregister(Project) @@ -15,4 +16,6 @@ class RedmineProjectInline(admin.StackedInline): class ProjectAdmin(ProjectAdmin): """Adfinis specific project including Redmine issue configuration.""" - inlines = ProjectAdmin.inlines + [RedmineProjectInline, ] + inlines = ProjectAdmin.inlines + [ + RedmineProjectInline, SubscriptionProjectInline + ] From dec43376c7220304d66a7206a604e2f19ef97139 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 3 Aug 2017 17:32:00 +0200 Subject: [PATCH 115/980] Add support subscriptions for parity with SSA portal These includes: * customer password * subscription and packages * orders * import from timescout --- timed/subscription/admin.py | 35 +++++++++++++++---- timed/subscription/migrations/0001_initial.py | 26 ++++++++++++-- timed/subscription/models.py | 34 ++++++++++++++++-- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py index b46997a18..d784a18ee 100644 --- a/timed/subscription/admin.py +++ b/timed/subscription/admin.py @@ -1,9 +1,15 @@ -"""Views for the admin interface.""" +import hashlib +from django import forms from django.contrib import admin +from timed.projects.admin import CustomerAdmin +from timed.projects.models import Customer + from . import models +admin.site.unregister(Customer) + @admin.register(models.Subscription) class SubscriptionAdmin(admin.ModelAdmin): @@ -19,9 +25,26 @@ class PackageAdmin(admin.ModelAdmin): list_display = ['subscription', 'duration', 'price'] -@admin.register(models.SubscriptionProject) -class SubscriptionProjectAdmin(admin.ModelAdmin): - """Subscription project assignment admin view.""" +class SubscriptionProjectInline(admin.StackedInline): + model = models.SubscriptionProject + + +class CustomerPasswordForm(forms.ModelForm): + def save(self, commit=True): + password = self.cleaned_data.get('password') + if password is not None: + self.instance.password = hashlib.md5( + password.encode()).hexdigest() + return super().save(commit=commit) + + +class CustomerPasswordInline(admin.StackedInline): + form = CustomerPasswordForm + model = models.CustomerPassword + - list_display = ['project', 'subscription'] - list_filter = ['project'] +@admin.register(Customer) +class CustomerAdmin(CustomerAdmin): + inlines = CustomerAdmin.inlines + [ + CustomerPasswordInline + ] diff --git a/timed/subscription/migrations/0001_initial.py b/timed/subscription/migrations/0001_initial.py index 85b67278e..a4d386549 100644 --- a/timed/subscription/migrations/0001_initial.py +++ b/timed/subscription/migrations/0001_initial.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-31 10:51 +# Generated by Django 1.11.4 on 2017-08-03 14:51 from __future__ import unicode_literals from decimal import Decimal +from django.conf import settings from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone import djmoney.models.fields @@ -13,10 +15,30 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('projects', '0001_initial'), ] operations = [ + migrations.CreateModel( + name='CustomerPassword', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(blank=True, max_length=128, null=True, verbose_name='password')), + ('customer', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='projects.Customer')), + ], + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('duration', models.DurationField()), + ('ordered', models.DateTimeField(default=django.utils.timezone.now)), + ('acknowledged', models.BooleanField(default=False)), + ('confirmedby', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders_confirmed', to=settings.AUTH_USER_MODEL)), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='projects.Project')), + ], + ), migrations.CreateModel( name='Package', fields=[ @@ -38,7 +60,7 @@ class Migration(migrations.Migration): name='SubscriptionProject', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='projects.Project')), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='projects.Project')), ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='subscription.Subscription')), ], ), diff --git a/timed/subscription/models.py b/timed/subscription/models.py index dfff9cd23..0a99be1d1 100644 --- a/timed/subscription/models.py +++ b/timed/subscription/models.py @@ -1,6 +1,7 @@ -from __future__ import unicode_literals - +from django.conf import settings from django.db import models +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ from djmoney.models.fields import MoneyField @@ -35,6 +36,33 @@ class SubscriptionProject(models.Model): A project can only be assigned to one subscription. """ + project = models.OneToOneField('projects.Project') + subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE) + + +class Order(models.Model): + """Order of customer for specific amount of hours.""" + project = models.OneToOneField('projects.Project', + on_delete=models.CASCADE, related_name='subscription') - subscription = models.ForeignKey(Subscription) + duration = models.DurationField() + ordered = models.DateTimeField(default=timezone.now) + acknowledged = models.BooleanField(default=False) + confirmedby = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='orders_confirmed') + + +class CustomerPassword(models.Model): + """ + Password per customer used for login into SySupport portal. + + Password are only hashed with md5. This model will be obsolete + once customer center will go live. + """ + + customer = models.OneToOneField('projects.Customer') + password = models.CharField(_('password'), max_length=128, + null=True, blank=True) From 549631e1a398e1d2d3385fdd216682d6ba73650a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 4 Aug 2017 10:14:18 +0200 Subject: [PATCH 116/980] Do not fail when a Redmine issue cannot be found. Instead an error is written to stderr and report is skipped. --- .../management/commands/redmine_report.py | 45 +++++++++++-------- timed/redmine/tests/test_redmine_report.py | 20 +++++++++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 15eb9c4e0..f781d2e11 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -1,3 +1,4 @@ +import sys from datetime import timedelta import redminelib @@ -27,7 +28,7 @@ def handle(self, *args, **options): redmine = redminelib.Redmine( settings.REDMINE_URL, key=settings.REDMINE_APIKEY, - requets={ + requests={ 'auth': ( settings.REDMINE_HTACCESS_USER, settings.REDMINE_HTACCESS_PASSWORD @@ -55,21 +56,29 @@ def handle(self, *args, **options): for project in projects: total_hours = project.total_hours.total_seconds() / 3600 - issue = redmine.issue.get(project.redmine_project.issue_id) - reports = Report.objects.filter( - task__project=project, updated__range=[start, end] - ).order_by('date') - hours = reports.aggregate(hours=Sum('duration'))['hours'] + try: + issue = redmine.issue.get(project.redmine_project.issue_id) + reports = Report.objects.filter( + task__project=project, updated__range=[start, end] + ).order_by('date') + hours = reports.aggregate(hours=Sum('duration'))['hours'] - issue.notes = render_to_string('redmine/weekly_report.txt', { - 'project': project, - 'hours': hours.total_seconds() / 3600, - 'last_days': last_days, - 'total_hours': total_hours, - 'reports': reports - }) - issue.custom_fields = [{ - 'id': settings.REDMINE_SPENTHOURS_FIELD, - 'value': total_hours - }] - issue.save() + issue.notes = render_to_string('redmine/weekly_report.txt', { + 'project': project, + 'hours': hours.total_seconds() / 3600, + 'last_days': last_days, + 'total_hours': total_hours, + 'reports': reports + }) + issue.custom_fields = [{ + 'id': settings.REDMINE_SPENTHOURS_FIELD, + 'value': total_hours + }] + issue.save() + except redminelib.exceptions.ResourceNotFoundError: + sys.stderr.write( + 'Project {0} has an invalid Redmine ' + 'issue {1} assigned. Skipping'.format( + project.name, project.redmine_project.issue_id + ) + ) diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index a4c56a26b..4074c6d6e 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -1,5 +1,6 @@ import pytest from django.core.management import call_command +from redminelib.exceptions import ResourceNotFoundError from timed.tracking.factories import ReportFactory from timed_adfinis.redmine.models import RedmineProject @@ -37,3 +38,22 @@ def test_redmine_report(db, freezer, mocker): assert 'Total hours: {0}'.format(report_hours) in issue.notes assert 'Hours in last 7 days: {0}'.format(report_hours) in issue.notes issue.save.assert_called_once_with() + + +@pytest.mark.freeze_time +def test_redmine_report_invalid_issue(db, freezer, mocker, capsys): + """Test case when issue is not available.""" + redmine_instance = mocker.MagicMock() + redmine_class = mocker.patch('redminelib.Redmine') + redmine_class.return_value = redmine_instance + redmine_instance.issue.get.side_effect = ResourceNotFoundError() + + freezer.move_to('2017-07-28') + report = ReportFactory.create() + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + freezer.move_to('2017-07-31') + call_command('redmine_report', options={'--last-days': '7'}) + + _, err = capsys.readouterr() + assert 'issue 1000 assigned' in err From fb4a7cc94c30767008bdb0ba388a04afc7931923 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 28 Jul 2017 17:13:16 +0200 Subject: [PATCH 117/980] Add management command to notify supervisor on shorttime of their supervisees --- dev_requirements.txt | 1 + timed/employment/factories.py | 2 +- timed/employment/serializers.py | 72 ++++----- timed/reports/__init__.py | 0 timed/reports/management/__init__.py | 0 timed/reports/management/commands/__init__.py | 0 .../commands/notify_supervisors_shorttime.py | 137 ++++++++++++++++++ .../mail/notify_supervisor_shorttime.txt | 7 + timed/reports/tests/__init__.py | 0 .../test_notify_supervisors_shorttime.py | 51 +++++++ timed/settings.py | 2 + 11 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 timed/reports/__init__.py create mode 100644 timed/reports/management/__init__.py create mode 100644 timed/reports/management/commands/__init__.py create mode 100644 timed/reports/management/commands/notify_supervisors_shorttime.py create mode 100644 timed/reports/templates/mail/notify_supervisor_shorttime.txt create mode 100644 timed/reports/tests/__init__.py create mode 100644 timed/reports/tests/test_notify_supervisors_shorttime.py diff --git a/dev_requirements.txt b/dev_requirements.txt index 642f75922..6d07d0e9d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -14,5 +14,6 @@ isort pytest pytest-cov pytest-django +pytest-freezegun==0.1.0 sphinx sphinx_rtd_theme diff --git a/timed/employment/factories.py b/timed/employment/factories.py index e5e8370ae..35de65e85 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -77,7 +77,7 @@ def worktime_per_day(self): :return: The generated worktime :rtype: datetime.timedelta """ - return datetime.timedelta(minutes=60 * 8.5 * self.percentage) + return datetime.timedelta(minutes=60 * 8.5 * self.percentage / 100) class Meta: """Meta informations for the employment factory.""" diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 0e6d31fb5..89c407445 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -20,8 +20,8 @@ class UserSerializer(ModelSerializer): absence_credits = ResourceRelatedField(many=True, read_only=True) worktime_balance = SerializerMethodField() - def get_worktime_balance_raw(self, instance): - """Calculate the worktime balance for the user. + def get_worktime(self, user, start=None, end=None): + """Calculate the reported, expected and balance for user. 1. Determine the current employment of the user 2. Take the latest of those two as start date: @@ -42,36 +42,38 @@ def get_worktime_balance_raw(self, instance): 10. The balance is the reported time plus the absences plus the overtime credit minus the expected worktime - :returns: The worktime balance of the user - :rtype: datetime.timedelta + :param user: user to get worktime from + :param start_date: worktime starting on given day; + if not set when employment started resp. begining of + the year + :param end_date: worktime till day or if not set today + :returns: tuple of 3 values reported, expected and balance in given + time frame """ employment = models.Employment.objects.filter( - user=instance, + user=user, end_date__isnull=True ).first() # If there is no active employment, set the balance to 0 if employment is None: - return timedelta() + return timedelta(), timedelta(), timedelta() location = employment.location - request = self.context.get('request') - requested_end_date = request.query_params.get('until') + if start is None: + start = max( + employment.start_date, date(date.today().year, 1, 1) + ) - start_date = max(employment.start_date, date(date.today().year, 1, 1)) - end_date = ( - datetime.strptime(requested_end_date, '%Y-%m-%d').date() - if requested_end_date - else date.today() - ) + end = end or date.today() # workdays is in isoweekday, byweekday expects Monday to be zero week_workdays = [int(day) - 1 for day in employment.location.workdays] workdays = rrule.rrule( rrule.DAILY, - dtstart=start_date, - until=end_date, + dtstart=start, + until=end, byweekday=week_workdays ).count() @@ -83,8 +85,8 @@ def get_worktime_balance_raw(self, instance): ] holidays = models.PublicHoliday.objects.filter( location=location, - date__gte=start_date, - date__lte=end_date, + date__gte=start, + date__lte=end, date__week_day__in=workdays_db ).count() @@ -92,37 +94,34 @@ def get_worktime_balance_raw(self, instance): overtime_credit = sum( models.OvertimeCredit.objects.filter( - user=instance, - date__gte=start_date, - date__lte=end_date + user=user, + date__gte=start, + date__lte=end ).values_list('duration', flat=True), timedelta() ) reported_worktime = sum( Report.objects.filter( - user=instance, - date__gte=start_date, - date__lte=end_date + user=user, + date__gte=start, + date__lte=end ).values_list('duration', flat=True), timedelta() ) absences = sum( Absence.objects.filter( - user=instance, - date__gte=start_date, - date__lte=end_date + user=user, + date__gte=start, + date__lte=end ).values_list('duration', flat=True), timedelta() ) - return ( - reported_worktime + - absences + - overtime_credit - - expected_worktime - ) + reported = reported_worktime + absences + overtime_credit + + return (reported, expected_worktime, reported - expected_worktime) def get_worktime_balance(self, instance): """Format the worktime balance. @@ -130,9 +129,12 @@ def get_worktime_balance(self, instance): :return: The formatted worktime balance. :rtype: str """ - worktime_balance = self.get_worktime_balance_raw(instance) + request = self.context.get('request') + until = request.query_params.get('until') + end_date = until and datetime.strptime(until, '%Y-%m-%d').date() - return duration_string(worktime_balance) + _, _, balance = self.get_worktime(instance, None, end_date) + return duration_string(balance) included_serializers = { 'employments': diff --git a/timed/reports/__init__.py b/timed/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/reports/management/__init__.py b/timed/reports/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/reports/management/commands/__init__.py b/timed/reports/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py new file mode 100644 index 000000000..80f9c8dda --- /dev/null +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -0,0 +1,137 @@ +from datetime import date, timedelta + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.mail import send_mass_mail +from django.core.management.base import BaseCommand +from django.template.loader import render_to_string + +from timed.employment.serializers import UserSerializer + + +class Command(BaseCommand): + """ + Send notification when supervisees have shorttime in given time frame. + + Example how it works: + + We have set following options + + Today = Thursday 27/7/2017 + Days = 7 + Offset = 5 + Ratio = 0.9 + + with these set shorttime would be checked between 17/7/2017 and 23/7/2017. + A notification will be sent to supervisors if ratio between reported and + expected worktime is lower than 90%. + """ + + help = 'Notify supervisors when supervisees have reported shortime.' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.serializer = UserSerializer() + + def add_arguments(self, parser): + parser.add_argument( + '--days', + default=7, + type=int, + dest='days', + help='Length of period to check shorttime in' + ) + parser.add_argument( + '--offset', + default=5, + type=int, + dest='offset', + help='Period will end today minus given offset.' + ) + parser.add_argument( + '--ratio', + default=0.9, + type=float, + dest='ratio', + help=( + 'Ratio between expected and reported time ' + 'before it is considered shorttime' + ) + ) + + def handle(self, *args, **options): + days = options['days'] + offset = options['offset'] + ratio = options['ratio'] + + today = date.today() + # -1 as we also skip today + end = today - timedelta(days=offset - 1) + start = end - timedelta(days=days - 1) + + supervisees = self._get_supervisees_with_shorttime(start, end, ratio) + self._notify_supervisors(start, end, ratio, supervisees) + + def _decimal_hours(self, duration): + return duration.total_seconds() / 3600 + + def _get_supervisees_with_shorttime(self, start, end, ratio): + """ + Get supervisees which reported less hours than they should have. + + :return: dict mapping all supervisees with shorttime with tuple of + reported, expected, balance and actual ratio. + """ + supervisees_shorttime = {} + supervisees = get_user_model().objects.all_supervisees() + for supervisee in supervisees: + worktime = self.serializer.get_worktime(supervisee, start, end) + reported, expected, balance = worktime + if expected == timedelta(0): + continue + + supervisee_ratio = reported / expected + if supervisee_ratio < ratio: + supervisees_shorttime[supervisee.id] = ( + self._decimal_hours(reported), + self._decimal_hours(expected), + self._decimal_hours(balance), + supervisee_ratio + ) + + return supervisees_shorttime + + def _notify_supervisors(self, start, end, ratio, supervisees): + """ + Notify supervisors about their supervisees. + + :param supervisees: dict whereas key is id of supervisee and + value as a worktime tuple of + reported, expected, balance and ratio + """ + supervisors = get_user_model().objects.all_supervisors() + subject = '[Timed] Report supervisees with shorttime' + from_email = settings.DEFAULT_FROM_EMAIL + mails = [] + + for supervisor in supervisors: + suspects = supervisor.supervisees.filter( + id__in=supervisees.keys()).order_by('first_name') + suspects_shorttime = [ + (suspect, supervisees[suspect.id]) for suspect in suspects + ] + if suspects.count() > 0 and supervisor.email: + body = render_to_string( + 'mail/notify_supervisor_shorttime.txt', { + 'start': start, + 'end': end, + 'ratio': ratio, + # format: + # [(user, (reported, expected, balance, ratio)), ...] + 'suspects': suspects_shorttime + } + ) + mails.append((subject, body, from_email, [supervisor.email])) + + if len(mails) > 0: + send_mass_mail(mails) diff --git a/timed/reports/templates/mail/notify_supervisor_shorttime.txt b/timed/reports/templates/mail/notify_supervisor_shorttime.txt new file mode 100644 index 000000000..971e89fb3 --- /dev/null +++ b/timed/reports/templates/mail/notify_supervisor_shorttime.txt @@ -0,0 +1,7 @@ +{% load humanize %} +Time range: {{start}} - {{end}} +Ratio: {{ratio}} + +{% for suspect, worktime in suspects %} +{{suspect.get_full_name}} {{worktime.0}}/{{worktime.1}} (Ratio {{worktime.3|floatformat:2}} Balance {{worktime.2}}) +{% endfor %} diff --git a/timed/reports/tests/__init__.py b/timed/reports/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py new file mode 100644 index 000000000..28101a09d --- /dev/null +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -0,0 +1,51 @@ +from datetime import date, timedelta + +import pytest +from dateutil.rrule import DAILY, FR, MO, rrule +from django.core.management import call_command + +from timed.employment.factories import EmploymentFactory, UserFactory +from timed.tracking.factories import ReportFactory + + +@pytest.mark.freeze_time('2017-7-27') +def test_notify_supervisors(db, mailoutbox): + """Test time range 2017-7-17 till 2017-7-23.""" + start = date(2017, 1, 1) + # supervisee with short time + supervisee = UserFactory.create() + supervisor = UserFactory.create() + supervisee.supervisors.add(supervisor) + + EmploymentFactory.create(user=supervisee, + start_date=start, + percentage=100) + workdays = rrule(DAILY, dtstart=start, until=date.today(), + # range is excluding last + byweekday=range(MO.weekday, FR.weekday + 1)) + for dt in workdays: + ReportFactory.create(user=supervisee, date=dt, + duration=timedelta(hours=7)) + + call_command('notify_supervisors_shorttime') + + # checks + assert len(mailoutbox) == 1 + mail = mailoutbox[0] + assert mail.to == [supervisor.email] + body = mail.body + assert 'Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9' in body + expected = '{0} 35.0/42.5 (Ratio 0.82 Balance -7.5)'.format( + supervisee.get_full_name()) + assert expected in body + + +def test_notify_supervisors_no_employment(db, mailoutbox): + """Check that supervisees without employment do not notify supervisor.""" + supervisee = UserFactory.create() + supervisor = UserFactory.create() + supervisee.supervisors.add(supervisor) + + call_command('notify_supervisors_shorttime') + + assert len(mailoutbox) == 0 diff --git a/timed/settings.py b/timed/settings.py index e464cb9ce..ece74fe34 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -31,6 +31,7 @@ INSTALLED_APPS = [ 'django.contrib.admin', + 'django.contrib.humanize', 'multiselectfield', 'django.forms', 'django.contrib.auth', @@ -43,6 +44,7 @@ 'timed.employment', 'timed.projects', 'timed.tracking', + 'timed.reports', ] MIDDLEWARE_CLASSES = [ From 2a98d44d73e39e8bf16dfecc0035f40a715cd5a3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 4 Aug 2017 12:19:16 +0200 Subject: [PATCH 118/980] Improve performance of Report export Every report would have queried its project and tasks which is now avoided. --- timed/tracking/tests/test_report.py | 43 ++++++++++++++++------------- timed/tracking/views.py | 2 +- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index ad0fd3113..90340d3b5 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -9,8 +9,8 @@ from hypothesis import given, settings from hypothesis.extra.django import TestCase from hypothesis.extra.django.models import models -from hypothesis.strategies import (builds, characters, dates, sampled_from, - timedeltas) +from hypothesis.strategies import (builds, characters, dates, lists, + sampled_from, timedeltas) from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) @@ -326,32 +326,37 @@ def test_absence_update_on_create_report(self): class TestReportHypo(TestCase): @given( sampled_from(['csv', 'xlsx', 'ods']), - models( - Report, - comment=characters(blacklist_categories=['Cc', 'Cs']), - task=builds(TaskFactory.create), - user=builds(UserFactory.create), - date=dates( - min_date=date(2000, 1, 1), - max_date=date(2100, 1, 1), + lists( + models( + Report, + comment=characters(blacklist_categories=['Cc', 'Cs']), + task=builds(TaskFactory.create), + user=builds(UserFactory.create), + date=dates( + min_date=date(2000, 1, 1), + max_date=date(2100, 1, 1), + ), + duration=timedeltas( + min_delta=timedelta(0), + max_delta=timedelta(days=1) + ) ), - duration=timedeltas( - min_delta=timedelta(0), - max_delta=timedelta(days=1) - ) + min_size=1, + max_size=5, ) ) @settings(timeout=5) - def test_report_export(self, file_type, report): + def test_report_export(self, file_type, reports): get_user_model().objects.create_user(username='test', password='1234qwer') client = JSONAPIClient() client.login('test', '1234qwer') url = reverse('report-export') - user_res = client.get(url, data={ - 'file_type': file_type - }) + with self.assertNumQueries(4): + user_res = client.get(url, data={ + 'file_type': file_type + }) assert user_res.status_code == HTTP_200_OK book = pyexcel.get_book( @@ -359,4 +364,4 @@ def test_report_export(self, file_type, report): ) # bookdict is a dict of tuples(name, content) sheet = book.bookdict.popitem()[1] - assert len(sheet) == 2 + assert len(sheet) == len(reports) + 1 diff --git a/timed/tracking/views.py b/timed/tracking/views.py index d753d62cd..d68d0d400 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -122,7 +122,7 @@ def get_queryset(self): 'task', 'user', 'activity' - ) + ).prefetch_related('task__project', 'task__project__customer') class AbsenceViewSet(ModelViewSet): From 97da127f3f963205ce62ab8552f61c994d47b788 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 7 Aug 2017 10:52:49 +0200 Subject: [PATCH 119/980] Improve usability of user admin view. Employments, absences and overtime credits can now be configured directly inline of the given user. --- timed/employment/admin.py | 70 ++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 33998d763..40ec7d988 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -19,22 +19,6 @@ class SupervisorInline(admin.TabularInline): verbose_name_plural = _('Supervisors') -@admin.register(models.User) -class UserAdmin(UserAdmin): - """Timed specific user admin.""" - - inlines = [SupervisorInline] - exclude = ('supervisors', ) - - -@admin.register(models.Location) -class LocationAdmin(admin.ModelAdmin): - """Location admin view.""" - - list_display = ['name'] - search_fields = ['name'] - - class EmploymentForm(forms.ModelForm): """Custom form for the employment admin.""" @@ -95,13 +79,39 @@ class Meta: model = models.Employment -@admin.register(models.Employment) -class EmploymentAdmin(admin.ModelAdmin): - """Employment admin view.""" +class EmploymentInline(admin.TabularInline): + form = EmploymentForm + model = models.Employment + extra = 0 + + +class OvertimeCreditInline(admin.TabularInline): + model = models.OvertimeCredit + extra = 0 + + +class AbsenceCreditInline(admin.TabularInline): + model = models.AbsenceCredit + extra = 0 + - form = EmploymentForm - list_display = ['__str__', 'percentage', 'location'] - list_filter = ['location', 'user'] +@admin.register(models.User) +class UserAdmin(UserAdmin): + """Timed specific user admin.""" + + inlines = [ + SupervisorInline, EmploymentInline, + OvertimeCreditInline, AbsenceCreditInline + ] + exclude = ('supervisors', ) + + +@admin.register(models.Location) +class LocationAdmin(admin.ModelAdmin): + """Location admin view.""" + + list_display = ['name'] + search_fields = ['name'] @admin.register(models.PublicHoliday) @@ -117,19 +127,3 @@ class AbsenceTypeAdmin(admin.ModelAdmin): """Absence type admin view.""" list_display = ['name'] - - -@admin.register(models.AbsenceCredit) -class AbsenceCreditAdmin(admin.ModelAdmin): - """Absence credit admin view.""" - - list_display = ['absence_type', 'user', 'duration', 'date'] - list_filter = ['absence_type', 'user'] - - -@admin.register(models.OvertimeCredit) -class OvertimeCreditAdmin(admin.ModelAdmin): - """Overtime credit admin view.""" - - list_display = ['user', 'duration', 'date'] - list_filter = ['user'] From 803aa58ca4916d0abf9686446cd1da5534583564 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Aug 2017 11:28:42 +0200 Subject: [PATCH 120/980] Fix occasionally failing test. If random will have a duration of 1 hour this test would fail. --- timed/tracking/tests/test_report.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 90340d3b5..3fbc47117 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -208,6 +208,8 @@ def test_report_update_date_staff(self): def test_report_update_duration_staff(self): report = self.other_reports[0] + report.duration = timedelta(hours=2) + report.save() data = { 'data': { From 45c85e567bd5c2f3107ed9eff654a84bee541ebf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Aug 2017 11:36:15 +0200 Subject: [PATCH 121/980] Disable too_slow health check. On travis it would occasionally be too slow to generate data but as there is a timeout configured this is not a problem. --- timed/tracking/tests/test_report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 3fbc47117..8ff9d11de 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.utils.duration import duration_string -from hypothesis import given, settings +from hypothesis import HealthCheck, given, settings from hypothesis.extra.django import TestCase from hypothesis.extra.django.models import models from hypothesis.strategies import (builds, characters, dates, lists, @@ -347,7 +347,7 @@ class TestReportHypo(TestCase): max_size=5, ) ) - @settings(timeout=5) + @settings(timeout=5, suppress_health_check=[HealthCheck.too_slow]) def test_report_export(self, file_type, reports): get_user_model().objects.create_user(username='test', password='1234qwer') From 844aa9e1a491b1f7667f6a0d53fb5563e4be9639 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Aug 2017 10:17:07 +0200 Subject: [PATCH 122/980] Configure django app with environment variables First step to containerization --- .gitignore | 3 + .travis.yml | 3 + README.md | 4 +- setup.py | 1 + timed/settings.py | 173 +++++++++++++++++++++++++--------------------- 5 files changed, 104 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index 3391b7257..f43a7dba7 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ target/ # Editor swap files *.swp + +# local .env file +.env diff --git a/.travis.yml b/.travis.yml index 6e2c62a03..2d14310c8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ language: python +env: + - ENV=travis + python: - "3.5.2" diff --git a/README.md b/README.md index 18ed0de8f..cd5134166 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ Timed timetracking software REST API built with Django ## Installation **Requirements** -* python 3.5.2 +* python 3.5.2 * docker * docker-compose After installing and configuring those requirements, you should be able to run the following commands to complete the installation: ```bash +$ echo "ENV=dev" >> .env # Django settings will be configured for development $ make install # Install Python requirements -$ cp timed/config.sample.ini timed/config.ini # Use the sample config file $ docker-compose up -d # Start the containers $ make setup-ldap # Configure UCS LDAP container $ make create-ldap-user # Create a new standard user diff --git a/setup.py b/setup.py index 4a2420165..8ea566884 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ 'django-excel==0.0.9', 'pyexcel-ods3==0.4.0', 'pyexcel-xlsx==0.4.1', + 'django-environ==0.4.3', ), keywords='timetracking', url='https://adfinis-sygroup.ch/', diff --git a/timed/settings.py b/timed/settings.py index ece74fe34..1d0187387 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -1,34 +1,41 @@ -""" -Django settings for timed project. +import datetime +import os -Generated by 'django-admin startproject' using Django 1.9.2. +import environ -For more information on this file, see -https://docs.djangoproject.com/en/1.9/topics/settings/ +env = environ.Env() -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.9/ref/settings/ -""" +django_root = environ.Path(__file__) - 2 -import datetime -import os +ENV_FILE = env.str('DJANGO_ENV_FILE', default=django_root('.env')) +if os.path.exists(ENV_FILE): + environ.Env.read_env(ENV_FILE) + +# per default production is enabled for security reasons +# for development create .env file with ENV=dev +ENV = env.str('ENV', 'prod') -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ +def default(default_dev=env.NOTSET, default_prod=env.NOTSET): + """Environment aware default.""" + return ENV == 'prod' and default_prod or default_dev -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'jpfx&3nyjat!)g1vbp7n=#6cgeu*vnwyymxehm-%jc+482%^ej' -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +# Database definition + +DATABASE_URL = default('psql://timed:timed@127.0.0.1:5432/timed') +DATABASES = { + 'default': env.db(default=DATABASE_URL) +} -ALLOWED_HOSTS = ['*'] # Application definition +DEBUG = env.bool('DJANGO_DEBUG', default=default(True, False)) +SECRET_KEY = env.str('DJANGO_SECRET_KEY', default=default('uuuuuuuuuu')) +ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=default(['*'])) + + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.humanize', @@ -65,7 +72,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'timed', 'templates')], + 'DIRS': [django_root('timed', 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -81,69 +88,37 @@ WSGI_APPLICATION = 'timed.wsgi.application' -# Database -# https://docs.djangoproject.com/en/1.9/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'timed', - 'USER': 'timed', - 'PASSWORD': 'timed', - 'HOST': 'localhost' - } -} - - -# Password validation -# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa - }, -] - - # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ LOCALE_PATHS = [ - os.path.join(BASE_DIR, 'timed/locale') + django_root('timed', 'locale') ] LANGUAGE_CODE = 'en-US' -TIME_ZONE = 'Europe/Zurich' +TIME_ZONE = env.str('DJANGO_TIME_ZONE', 'Europe/Zurich') USE_I18N = True USE_L10N = True -DATETIME_FORMAT = 'd.m.Y H:i:s' -DATE_FORMAT = 'd.m.Y' -TIME_FORMAT = 'H:i:s' +DATETIME_FORMAT = env.str('DJANGO_DATETIME_FORMAT', 'd.m.Y H:i:s') +DATE_FORMAT = env.str('DJANGO_DATE_FORMAT', 'd.m.Y') +TIME_FORMAT = env.str('DJANGO_TIME_FORMAT', 'H:i:s') -DECIMAL_SEPARATOR = '.' +DECIMAL_SEPARATOR = env.str('DECIMAL_SEPARATOR', '.') USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = env.str('STATIC_URL', '/static/') +STATIC_ROOT = env.str('STATIC_ROOT', None) + +# Rest framework definition REST_FRAMEWORK = { - 'PAGINATE_BY': None, - 'MAX_PAGINATE_BY': 100, 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework.filters.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', @@ -176,6 +151,40 @@ ), } +JSON_API_FORMAT_KEYS = 'dasherize' +JSON_API_FORMAT_TYPES = 'dasherize' +JSON_API_PLURALIZE_TYPES = True + +APPEND_SLASH = False + +# Authentication definition + +AUTH_LDAP_USER_ATTR_MAP = env.dict('DJANGO_AUTH_LDAP_USER_ATTR_MAP', default={ + 'first_name': 'givenName', + 'last_name': 'sn', + 'email': 'mail' +}) + +LDAP_BASE = 'dc=adsy-ext,dc=becs,dc=adfinis-sygroup,dc=ch' +AUTH_LDAP_SERVER_URI = env.str( + 'DJANGO_AUTH_LDAP_SERVER_URI', + default('ldap://localhost:389') +) +AUTH_LDAP_BIND_DN = env.str( + 'DJANGO_AUTH_LDAP_BIND_DN', + default('uid=Administrator,cn=users,{0}'.format(LDAP_BASE)) +) +AUTH_LDAP_PASSWORD = env.str( + 'DJANGO_AUTH_LDAP_PASSWORD', + default('univention') +) +AUTH_LDAP_USER_DN_TEMPLATE = env.str( + 'DJANGO_AUTH_LDAP_USER_DN_TEMPLATE', + default('uid=%(user)s,cn=users,{0}'.format(LDAP_BASE)) +) + +AUTH_USER_MODEL = 'employment.User' + AUTHENTICATION_BACKENDS = ( 'django_auth_ldap.backend.LDAPBackend', 'django.contrib.auth.backends.ModelBackend', @@ -188,22 +197,30 @@ 'JWT_AUTH_HEADER_PREFIX': 'Bearer', } -JSON_API_FORMAT_KEYS = 'dasherize' -JSON_API_FORMAT_TYPES = 'dasherize' -JSON_API_PLURALIZE_TYPES = True - -APPEND_SLASH = False +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa + }, +] -AUTH_LDAP_USER_ATTR_MAP = { - 'first_name': 'givenName', - 'last_name': 'sn', - 'email': 'mail' -} +# Email definition -LDAP_BASE = 'dc=adsy-ext,dc=becs,dc=adfinis-sygroup,dc=ch' -AUTH_LDAP_SERVER_URI = 'ldap://localhost:389' -AUTH_LDAP_BIND_DN = 'uid=Administrator,cn=users,{0}'.format(LDAP_BASE) -AUTH_LDAP_PASSWORD = 'univention' -AUTH_LDAP_USER_DN_TEMPLATE = 'uid=%(user)s,cn=users,{0}'.format(LDAP_BASE) +EMAIL_CONFIG = env.email_url( + 'EMAIL_URL', + default='smtp://localhost:25' +) +vars().update(EMAIL_CONFIG) -AUTH_USER_MODEL = 'employment.User' +DEFAULT_FROM_EMAIL = env.str( + 'DJANGO_DEFAULT_FROM_EMAIL', + default('webmaster@localhost') +) From b134a440776ce5cd2a9afdcc272c675f6d177600 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Aug 2017 11:30:16 +0200 Subject: [PATCH 123/980] Add possibility to configure ADMINS per environ --- .travis.yml | 4 +--- timed/settings.py | 22 ++++++++++++++++++++++ timed/test_settings.py | 15 +++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 timed/test_settings.py diff --git a/.travis.yml b/.travis.yml index 2d14310c8..3d1d15bb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,5 @@ language: python -env: - - ENV=travis - python: - "3.5.2" @@ -15,6 +12,7 @@ cache: - .hypothesis install: + - echo "ENV=travis" > .env - make install-dev - pip install coveralls diff --git a/timed/settings.py b/timed/settings.py index 1d0187387..19136f451 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -1,5 +1,6 @@ import datetime import os +import re import environ @@ -224,3 +225,24 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'DJANGO_DEFAULT_FROM_EMAIL', default('webmaster@localhost') ) + + +def parse_admins(admins): + """ + Parse env admins to django admins. + + Example of DJANGO_ADMINS environment variable: + Test Example ,Test2 + """ + result = [] + for admin in admins: + match = re.search('(.+) \<(.+@.+)\>', admin) + if not match: + raise environ.ImproperlyConfigured( + 'In DJANGO_ADMINS admin "{0}" is not in correct ' + '"Firstname Lastname "'.format(admin)) + result.append((match.group(1), match.group(2))) + return result + + +ADMINS = parse_admins(env.list('DJANGO_ADMINS', default=[])) diff --git a/timed/test_settings.py b/timed/test_settings.py new file mode 100644 index 000000000..79881d000 --- /dev/null +++ b/timed/test_settings.py @@ -0,0 +1,15 @@ +import environ +import pytest + +from timed import settings + + +def test_admins(): + assert settings.parse_admins(['Test Example ']) == [ + ('Test Example', 'test@example.com'), + ] + + +def test_invalid_admins(monkeypatch): + with pytest.raises(environ.ImproperlyConfigured): + settings.parse_admins(['Test Example Date: Tue, 8 Aug 2017 14:50:18 +0200 Subject: [PATCH 124/980] add ordering fields to report view --- timed/tracking/views.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index d753d62cd..bafb84191 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -79,6 +79,15 @@ class ReportViewSet(ModelViewSet): filter_class = filters.ReportFilterSet permission_classes = [IsAuthenticated, IsOwnerOrStaffElseReadOnly] ordering = ('id', ) + ordering_fields = ( + 'date', + 'duration', + 'task__project__customer__name', + 'task__project__name', + 'task__name', + 'user__username', + 'comment', + ) @list_route() def export(self, request): From 1fbf8cb1fa31567e71ea25d06322f1da549d3879 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Aug 2017 14:04:12 +0200 Subject: [PATCH 125/980] Check that migrations are created if necessary --- .travis.yml | 1 + Makefile | 2 ++ .../migrations/0003_auto_20170808_1403.py | 22 +++++++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 timed/employment/migrations/0003_auto_20170808_1403.py diff --git a/.travis.yml b/.travis.yml index 3d1d15bb6..f454a081d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ install: before_script: - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres + - psql -c "CREATE DATABASE timed;" -U postgres script: make test diff --git a/Makefile b/Makefile index b51df6bf7..2fc4050b1 100644 --- a/Makefile +++ b/Makefile @@ -29,5 +29,7 @@ docs: @make -C docs/ html test: ## Test the project + ./manage.py migrate --noinput + ./manage.py makemigrations --check --dry-run --noinput @flake8 @pytest --cov --create-db diff --git a/timed/employment/migrations/0003_auto_20170808_1403.py b/timed/employment/migrations/0003_auto_20170808_1403.py new file mode 100644 index 000000000..fbeb1312b --- /dev/null +++ b/timed/employment/migrations/0003_auto_20170808_1403.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-08 12:03 +from __future__ import unicode_literals + +from django.db import migrations +import timed.employment.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0002_user_supervisors'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', timed.employment.models.UserManager()), + ], + ), + ] From efe88ee98fe918a97be762e2f2d21b7a3577f3d5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Aug 2017 09:11:20 +0200 Subject: [PATCH 126/980] Skip orders with invalid project ids --- .../migrations/0002_auto_20170808_1729.py | 21 +++++++++++++++++++ timed/subscription/models.py | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 timed/subscription/migrations/0002_auto_20170808_1729.py diff --git a/timed/subscription/migrations/0002_auto_20170808_1729.py b/timed/subscription/migrations/0002_auto_20170808_1729.py new file mode 100644 index 000000000..35e1d95d4 --- /dev/null +++ b/timed/subscription/migrations/0002_auto_20170808_1729.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-08 15:29 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscription', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='projects.Project'), + ), + ] diff --git a/timed/subscription/models.py b/timed/subscription/models.py index 0a99be1d1..e013e629c 100644 --- a/timed/subscription/models.py +++ b/timed/subscription/models.py @@ -43,9 +43,9 @@ class SubscriptionProject(models.Model): class Order(models.Model): """Order of customer for specific amount of hours.""" - project = models.OneToOneField('projects.Project', - on_delete=models.CASCADE, - related_name='subscription') + project = models.ForeignKey('projects.Project', + on_delete=models.CASCADE, + related_name='orders') duration = models.DurationField() ordered = models.DateTimeField(default=timezone.now) acknowledged = models.BooleanField(default=False) From 667caf49927709d40cc689ff195e3c7607b41a1b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Aug 2017 14:28:17 +0200 Subject: [PATCH 127/980] Allows json web token to be passed on as GET parameter This is mainly used in cases of file download. --- timed/authentication.py | 8 ++++++++ timed/jsonapi_test_case.py | 11 ++++++++--- timed/settings.py | 7 +------ 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 timed/authentication.py diff --git a/timed/authentication.py b/timed/authentication.py new file mode 100644 index 000000000..12c903d12 --- /dev/null +++ b/timed/authentication.py @@ -0,0 +1,8 @@ +from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication + + +class JSONWebTokenAuthenticationQueryParam(BaseJSONWebTokenAuthentication): + """Allows json web token to be passed on as GET parameter.""" + + def get_jwt_value(self, request): + return request.query_params.get('jwt') diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index f9bf4f453..008813931 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -85,11 +85,16 @@ def login(self, username, password): :raises: Exception """ data = { - 'username': username, - 'password': password + 'data': { + 'attributes': { + 'username': username, + 'password': password + }, + 'type': 'obtain-json-web-tokens', + } } - response = super().post(reverse('login'), data) + response = self.post(reverse('login'), data) if response.status_code != status.HTTP_200_OK: raise Exception('Wrong credentials!') # pragma: no cover diff --git a/timed/settings.py b/timed/settings.py index 19136f451..d1b7b8f1a 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -127,9 +127,6 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ), 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', - 'rest_framework.parsers.JSONParser', - 'rest_framework.parsers.FormParser', - 'rest_framework.parsers.MultiPartParser' ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', @@ -137,7 +134,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.BasicAuthentication', + 'timed.authentication.JSONWebTokenAuthenticationQueryParam', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', @@ -147,8 +144,6 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'rest_framework_json_api.pagination.PageNumberPagination', 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', - 'rest_framework.renderers.JSONRenderer', - 'rest_framework.renderers.BrowsableAPIRenderer', ), } From 6e31b3a27b756b2779e143a2f0079f27c551431f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 10 Aug 2017 11:10:24 +0200 Subject: [PATCH 128/980] Add supervisee admin panel per user --- timed/employment/admin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 40ec7d988..ac30cb7bc 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -19,6 +19,14 @@ class SupervisorInline(admin.TabularInline): verbose_name_plural = _('Supervisors') +class SuperviseeInline(admin.TabularInline): + model = models.User.supervisors.through + extra = 0 + fk_name = 'to_user' + verbose_name = _('Supervisee') + verbose_name_plural = _('Supervisees') + + class EmploymentForm(forms.ModelForm): """Custom form for the employment admin.""" @@ -100,7 +108,7 @@ class UserAdmin(UserAdmin): """Timed specific user admin.""" inlines = [ - SupervisorInline, EmploymentInline, + SupervisorInline, SuperviseeInline, EmploymentInline, OvertimeCreditInline, AbsenceCreditInline ] exclude = ('supervisors', ) From 8e1546fac3c87ab555ff5893729f495875794c48 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 10 Aug 2017 16:41:27 +0200 Subject: [PATCH 129/980] Fix the calculation of absence credits and balances. --- timed/employment/factories.py | 10 +- .../migrations/0003_auto_20170808_1023.py | 27 ++ .../migrations/0004_auto_20170809_1647.py | 34 +++ .../migrations/0005_merge_20170810_0924.py | 16 ++ timed/employment/models.py | 31 ++- timed/employment/serializers.py | 231 +++++++++++++----- timed/employment/tests/test_absence_credit.py | 208 ---------------- timed/employment/tests/test_user.py | 61 ++++- timed/employment/urls.py | 1 - timed/employment/views.py | 35 +-- 10 files changed, 335 insertions(+), 319 deletions(-) create mode 100644 timed/employment/migrations/0003_auto_20170808_1023.py create mode 100644 timed/employment/migrations/0004_auto_20170809_1647.py create mode 100644 timed/employment/migrations/0005_merge_20170810_0924.py delete mode 100644 timed/employment/tests/test_absence_credit.py diff --git a/timed/employment/factories.py b/timed/employment/factories.py index 35de65e85..ce931e2f8 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -103,15 +103,7 @@ class AbsenceCreditFactory(DjangoModelFactory): absence_type = SubFactory(AbsenceTypeFactory) user = SubFactory(UserFactory) date = Faker('date_object') - - @lazy_attribute - def duration(self): - """Generate a random duration. - - :return: The generated duration - :rtype: datetime.timedelta - """ - return datetime.timedelta(hours=random.randint(8, 200)) + days = Faker('random_int', min=1, max=25) class Meta: """Meta informations for the absence credit factory.""" diff --git a/timed/employment/migrations/0003_auto_20170808_1023.py b/timed/employment/migrations/0003_auto_20170808_1023.py new file mode 100644 index 000000000..887d19e24 --- /dev/null +++ b/timed/employment/migrations/0003_auto_20170808_1023.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-08 08:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import timed.employment.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0002_user_supervisors'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', timed.employment.models.UserManager()), + ], + ), + migrations.AddField( + model_name='absencecredit', + name='comment', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/timed/employment/migrations/0004_auto_20170809_1647.py b/timed/employment/migrations/0004_auto_20170809_1647.py new file mode 100644 index 000000000..f344b82cd --- /dev/null +++ b/timed/employment/migrations/0004_auto_20170809_1647.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-09 14:47 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0003_auto_20170808_1023'), + ] + + operations = [ + migrations.CreateModel( + name='UserAbsenceType', + fields=[ + ], + options={ + 'indexes': [], + 'proxy': True, + }, + bases=('employment.absencetype',), + ), + migrations.RemoveField( + model_name='absencecredit', + name='duration', + ), + migrations.AddField( + model_name='absencecredit', + name='days', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/timed/employment/migrations/0005_merge_20170810_0924.py b/timed/employment/migrations/0005_merge_20170810_0924.py new file mode 100644 index 000000000..cfe2b6c8c --- /dev/null +++ b/timed/employment/migrations/0005_merge_20170810_0924.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-10 07:24 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0003_auto_20170808_1403'), + ('employment', '0004_auto_20170809_1647'), + ] + + operations = [ + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index c69f97059..a3ad034bd 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -163,9 +163,10 @@ class AbsenceCredit(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='absence_credits') + comment = models.CharField(max_length=255, blank=True) absence_type = models.ForeignKey(AbsenceType) date = models.DateField() - duration = models.DurationField(blank=True, null=True) + days = models.PositiveIntegerField(default=0) class OvertimeCredit(models.Model): @@ -179,3 +180,31 @@ class OvertimeCredit(models.Model): related_name='overtime_credits') date = models.DateField() duration = models.DurationField(blank=True, null=True) + + +class UserAbsenceTypeManager(models.Manager): + def with_user(self, user): + return UserAbsenceType.objects.all().annotate( + user_id=models.Value(user.id, models.IntegerField()) + ) + + +class UserAbsenceType(AbsenceType): + """User absence type. + + This is a proxy for the absence type model used to generate a fake relation + between a user and an absence type. This is required so we can expose the + absence credits in a clean way to the API. + + The PK of this model is a combination of the user ID and the actual absence + type ID. + """ + + objects = UserAbsenceTypeManager() + + @property + def pk(self): + return '{0}-{1}'.format(self.user_id, self.id) + + class Meta: + proxy = True diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 89c407445..1e76edf3a 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -5,7 +5,8 @@ from dateutil import rrule from django.contrib.auth import get_user_model from django.utils.duration import duration_string -from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.relations import (ResourceRelatedField, # noqa + SerializerMethodResourceRelatedField) # noqa from rest_framework_json_api.serializers import (ModelSerializer, SerializerMethodField) @@ -17,9 +18,22 @@ class UserSerializer(ModelSerializer): """User serializer.""" employments = ResourceRelatedField(many=True, read_only=True) - absence_credits = ResourceRelatedField(many=True, read_only=True) worktime_balance = SerializerMethodField() + user_absence_types = SerializerMethodResourceRelatedField( + source='get_user_absence_types', + model=models.UserAbsenceType, + many=True, + read_only=True + ) + + def get_user_absence_types(self, instance): + """Get the user absence types for this user. + + :returns: All absence types for this user + """ + return models.UserAbsenceType.objects.with_user(instance) + def get_worktime(self, user, start=None, end=None): """Calculate the reported, expected and balance for user. @@ -139,8 +153,8 @@ def get_worktime_balance(self, instance): included_serializers = { 'employments': 'timed.employment.serializers.EmploymentSerializer', - 'absence_credits': - 'timed.employment.serializers.AbsenceCreditSerializer' + 'user_absence_types': + 'timed.employment.serializers.UserAbsenceTypeSerializer' } class Meta: @@ -153,9 +167,9 @@ class Meta: 'last_name', 'email', 'employments', - 'absence_credits', 'worktime_balance', 'is_staff', + 'user_absence_types', ] @@ -214,88 +228,176 @@ class Meta: ] -class AbsenceTypeSerializer(ModelSerializer): - """Absence type serializer.""" +class UserAbsenceTypeSerializer(ModelSerializer): + """Absence type serializer for a user. + + This is only a simulated relation to the user to show the absence credits + and balances. + """ + + credit = SerializerMethodField() + used_duration = SerializerMethodField() + used_days = SerializerMethodField() + balance = SerializerMethodField() + + absence_credits = SerializerMethodResourceRelatedField( + source='get_absence_credits', + model=models.AbsenceCredit, + many=True, + read_only=True + ) + + user = SerializerMethodResourceRelatedField( + source='get_user', + model=get_user_model(), + read_only=True + ) + + def get_absence_credits(self, instance): + """Get the absence credits for the user and type.""" + return models.AbsenceCredit.objects.filter( + absence_type=instance, + user_id=instance.user_id + ) - class Meta: - """Meta information for the absence type serializer.""" + def get_user(self, instance): + """Get the user of this user absence type.""" + return get_user_model().objects.get(pk=instance.user_id) - model = models.AbsenceType - fields = ['name', 'fill_worktime'] + def get_dates(self, instance): + """Get the start and end time for the credits. + :returns: A tuple of dates + """ + request = self.context.get('request') -class AbsenceCreditSerializer(ModelSerializer): - """Absence credit serializer.""" + end = datetime.strptime( + request.query_params.get( + 'until', + date.today().strftime('%Y-%m-%d') + ), + '%Y-%m-%d' + ) - absence_type = ResourceRelatedField(read_only=True) - user = ResourceRelatedField(read_only=True) - used = SerializerMethodField() - balance = SerializerMethodField() + user = get_user_model().objects.get(pk=instance.user_id) - def get_used_raw(self, instance): - """Calculate the total of used time since the date of the requested credit. + employment = models.Employment.objects.for_user(user, end) - This is the sum of all durations of reports, which are assigned to the - credits user, absence type and were created at or after the date of - this credit. + start = max( + employment.start_date, date(date.today().year, 1, 1) + ) - :return: The total of used time - :rtype: datetime.timedelta - """ - request = self.context.get('request') - requested_end_date = request.query_params.get('until') + return (start, end) - end_date = ( - datetime.strptime(requested_end_date, '%Y-%m-%d').date() - if requested_end_date - else date.today() - ) + def get_credit(self, instance): + """Calculate the total credited days. - reports = Absence.objects.filter( - user=instance.user, - type=instance.absence_type, - date__gte=instance.date, - date__lte=end_date - ).values_list('duration', flat=True) + :return: The total credited days + :rtype: int + """ + if instance.fill_worktime: + return None - return sum(reports, timedelta()) + start, end = self.get_dates(instance) - def get_balance_raw(self, instance): - """Calculate the balance of the requested credit. + return sum(models.AbsenceCredit.objects.filter( + user_id=instance.user_id, + absence_type=instance, + date__gte=start, + date__lte=end + ).values_list('days', flat=True)) - This is the difference between the credits duration and the total used - time. + def get_used_days(self, instance): + """Calculate the total used days. - :return: The balance - :rtype: datetime.timedelta + :return: The total used days + :rtype: int """ - return ( - instance.duration - self.get_used_raw(instance) - if instance.duration - else None - ) + if instance.fill_worktime: + return None - def get_used(self, instance): - """Format the total of used time. + start, end = self.get_dates(instance) - :return: The formatted total of used time + return len(Absence.objects.filter( + user_id=instance.user_id, + type=instance, + date__gte=start, + date__lte=end + )) + + def get_used_duration(self, instance): + """Calculate the total used duration. + + This is only calculated for types which fill the worktime such as + sickness. + + :return: The total used duration :rtype: str """ - used = self.get_used_raw(instance) + if not instance.fill_worktime: + return None - return duration_string(used) + start, end = self.get_dates(instance) - def get_balance(self, instance): - """Format the balance. + return duration_string(sum(Absence.objects.filter( + user_id=instance.user_id, + type=instance, + date__gte=start, + date__lte=end + ).values_list('duration', flat=True), timedelta())) - This is None if we don't have a duration. + def get_balance(self, instance): + """Calculate the balance of credited and used days. - :return: The formatted balance - :rtype: str or None + :return: The balance + :rtype: int """ - balance = self.get_balance_raw(instance) + credit = self.get_credit(instance) + + if instance.fill_worktime or credit is None: + return None - return duration_string(balance) if balance else None + return credit - self.get_used_days(instance) + + included_serializers = { + 'absence_credits': + 'timed.employment.serializers.AbsenceCreditSerializer', + } + + class Meta: + """Meta information for the absence type serializer.""" + + model = models.UserAbsenceType + fields = [ + 'name', + 'fill_worktime', + 'credit', + 'used_duration', + 'used_days', + 'balance', + 'absence_credits', + 'user', + ] + + +class AbsenceTypeSerializer(ModelSerializer): + """Absence type serializer.""" + + class Meta: + """Meta information for the absence type serializer.""" + + model = models.AbsenceType + fields = [ + 'name', + 'fill_worktime', + ] + + +class AbsenceCreditSerializer(ModelSerializer): + """Absence credit serializer.""" + + absence_type = ResourceRelatedField(read_only=True) + user = ResourceRelatedField(read_only=True) included_serializers = { 'absence_type': 'timed.employment.serializers.AbsenceTypeSerializer' @@ -309,9 +411,8 @@ class Meta: 'user', 'absence_type', 'date', - 'duration', - 'used', - 'balance', + 'days', + 'comment', ] diff --git a/timed/employment/tests/test_absence_credit.py b/timed/employment/tests/test_absence_credit.py deleted file mode 100644 index f69d78a1c..000000000 --- a/timed/employment/tests/test_absence_credit.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Tests for the absence credits endpoint.""" - -from datetime import timedelta - -from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) - -from timed.employment.factories import AbsenceCreditFactory, EmploymentFactory -from timed.jsonapi_test_case import JSONAPITestCase -from timed.tracking.factories import AbsenceFactory - - -class AbsenceCreditTests(JSONAPITestCase): - """Tests for the absence credits endpoint. - - This endpoint should be read only for normal users. - """ - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - - self.absence_credits = AbsenceCreditFactory.create_batch( - 5, - user=self.user - ) - - AbsenceCreditFactory.create_batch(5) - - def test_absence_credit_list(self): - """Should respond with a list of absence credits.""" - url = reverse('absence-credit-list') - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - assert len(result['data']) == len(self.absence_credits) - - def test_absence_credit_detail(self): - """Should respond with a single absence credit.""" - absence_credit = self.absence_credits[0] - - url = reverse('absence-credit-detail', args=[ - absence_credit.id - ]) - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - def test_absence_credit_create(self): - """Should not be able to create a new absence credit.""" - url = reverse('absence-credit-list') - - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_absence_credit_update(self): - """Should not be able to update an existing absence credit.""" - absence_credit = self.absence_credits[0] - - url = reverse('absence-credit-detail', args=[ - absence_credit.id - ]) - - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_absence_credit_delete(self): - """Should not be able delete an absence credit.""" - absence_credit = self.absence_credits[0] - - url = reverse('absence-credit-detail', args=[ - absence_credit.id - ]) - - noauth_res = self.noauth_client.delete(url) - res = self.client.delete(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_absence_credit_balance(self): - """Should calculate an absence credit balance.""" - absence_credit = AbsenceCreditFactory.create( - user=self.user, - duration=timedelta(hours=30) - ) - - EmploymentFactory.create( - user=self.user, - start_date=absence_credit.date - timedelta(days=1), - worktime_per_day=timedelta(hours=8) - ) - - AbsenceFactory.create( - user=self.user, - type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=1) - ) - - AbsenceFactory.create( - user=self.user, - type=absence_credit.absence_type, - date=absence_credit.date - timedelta(days=1), - duration=timedelta(hours=8) - ) - - url = reverse('absence-credit-detail', args=[ - absence_credit.id - ]) - - res = self.client.get(url) - result = self.result(res) - - assert result['data']['attributes']['duration'] == '1 06:00:00' - assert result['data']['attributes']['used'] == '08:00:00' - assert result['data']['attributes']['balance'] == '22:00:00' - - def test_absence_credit_balance_no_duration(self): - """Should not calculate an absence credit balance.""" - absence_credit = AbsenceCreditFactory.create( - user=self.user, - duration=None - ) - - EmploymentFactory.create( - user=self.user, - start_date=absence_credit.date, - worktime_per_day=timedelta(hours=8) - ) - - AbsenceFactory.create( - user=self.user, - type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=1) - ) - - url = reverse('absence-credit-detail', args=[ - absence_credit.id - ]) - - res = self.client.get(url) - result = self.result(res) - - assert result['data']['attributes']['duration'] is None - assert result['data']['attributes']['used'] == '08:00:00' - assert result['data']['attributes']['balance'] is None - - def test_absence_credit_balance_until(self): - """Should calculate a correct absence credit balance.""" - absence_credit = AbsenceCreditFactory.create( - user=self.user, - duration=timedelta(hours=30) - ) - - EmploymentFactory.create( - user=self.user, - start_date=absence_credit.date - timedelta(days=1), - worktime_per_day=timedelta(hours=8) - ) - - AbsenceFactory.create( - user=self.user, - type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=1) - ) - - AbsenceFactory.create( - user=self.user, - type=absence_credit.absence_type, - date=absence_credit.date - timedelta(days=1) - ) - - AbsenceFactory.create( - user=self.user, - type=absence_credit.absence_type, - date=absence_credit.date + timedelta(days=2) - ) - - url = reverse('absence-credit-detail', args=[ - absence_credit.id - ]) - - res = self.client.get('{0}?until={1}'.format( - url, - (absence_credit.date + timedelta(days=1)).strftime('%Y-%m-%d') - )) - - result = self.result(res) - - assert result['data']['attributes']['duration'] == '1 06:00:00' - assert result['data']['attributes']['used'] == '08:00:00' - assert result['data']['attributes']['balance'] == '22:00:00' diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 18aaf05cb..780abb618 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -8,7 +8,8 @@ from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) -from timed.employment.factories import (EmploymentFactory, +from timed.employment.factories import (AbsenceCreditFactory, + AbsenceTypeFactory, EmploymentFactory, OvertimeCreditFactory, PublicHolidayFactory, UserFactory) from timed.jsonapi_test_case import JSONAPITestCase @@ -213,3 +214,61 @@ def test_user_without_employment(self): assert result['data']['attributes']['worktime-balance'] == ( '00:00:00' ) + + def test_user_absence_types(self): + credits = AbsenceCreditFactory.create_batch(3, user=self.user) + + url = reverse('user-detail', args=[ + self.user.id + ]) + + res = self.client.get('{0}?include=user_absence_types'.format(url)) + + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + rel = result['data']['relationships'] + inc = result['included'] + + assert len(rel['user-absence-types']['data']) == len(credits) + assert len(inc) == len(credits) + + assert ( + inc[0]['id'] == + '{0}-{1}'.format(self.user.id, credits[0].absence_type.id) + ) + + assert type(inc[0]['attributes']['credit']) == int + assert type(inc[0]['attributes']['used-days']) == int + assert type(inc[0]['attributes']['balance']) == int + assert inc[0]['attributes']['used-duration'] is None + + def test_user_absence_types_fill_worktime(self): + absence_type = AbsenceTypeFactory.create(fill_worktime=True) + + url = reverse('user-detail', args=[ + self.user.id + ]) + + res = self.client.get('{0}?include=user_absence_types'.format(url)) + + assert res.status_code == HTTP_200_OK + + result = self.result(res) + + rel = result['data']['relationships'] + inc = result['included'] + + assert len(rel['user-absence-types']['data']) == 1 + assert len(inc) == 1 + + assert ( + inc[0]['id'] == + '{0}-{1}'.format(self.user.id, absence_type.id) + ) + + assert inc[0]['attributes']['credit'] is None + assert inc[0]['attributes']['used-days'] is None + assert inc[0]['attributes']['balance'] is None + assert type(inc[0]['attributes']['used-duration']) == str diff --git a/timed/employment/urls.py b/timed/employment/urls.py index b9d782754..e2a97a6b9 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -12,7 +12,6 @@ r.register(r'locations', views.LocationViewSet, 'location') r.register(r'public-holidays', views.PublicHolidayViewSet, 'public-holiday') r.register(r'absence-types', views.AbsenceTypeViewSet, 'absence-type') -r.register(r'absence-credits', views.AbsenceCreditViewSet, 'absence-credit') r.register(r'overtime-credits', views.OvertimeCreditViewSet, 'overtime-credit') urlpatterns = r.urls diff --git a/timed/employment/views.py b/timed/employment/views.py index 4d791cf68..0c57c8112 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -1,7 +1,5 @@ """Viewsets for the employment app.""" -from datetime import date, datetime - from django.contrib.auth import get_user_model from rest_framework.viewsets import ReadOnlyModelViewSet @@ -19,10 +17,7 @@ def get_queryset(self): :return: The filtered users :rtype: QuerySet """ - return get_user_model().objects.prefetch_related( - 'employments', - 'absence_credits' - ) + return get_user_model().objects.prefetch_related('employments') class EmploymentViewSet(ReadOnlyModelViewSet): @@ -79,34 +74,6 @@ class AbsenceTypeViewSet(ReadOnlyModelViewSet): ordering = ('name',) -class AbsenceCreditViewSet(ReadOnlyModelViewSet): - """Absence type view set.""" - - serializer_class = serializers.AbsenceCreditSerializer - - def get_queryset(self): - """Filter the queryset by the user of the request. - - :return: The filtered absence credits - :rtype: QuerySet - """ - requested_end_date = self.request.query_params.get('until') - - end_date = ( - datetime.strptime(requested_end_date, '%Y-%m-%d').date() - if requested_end_date - else date.today() - ) - - return models.AbsenceCredit.objects.select_related( - 'user', - 'absence_type' - ).filter( - user=self.request.user, - date__lte=end_date - ) - - class OvertimeCreditViewSet(ReadOnlyModelViewSet): """Absence type view set.""" From 2fc7e0e64543086b79ea0337b212e3a08aed9580 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 10 Aug 2017 17:31:33 +0200 Subject: [PATCH 130/980] JWT needs to be authenticated before django admin session --- timed/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index d1b7b8f1a..fe661b635 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -133,8 +133,8 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ), 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', 'timed.authentication.JSONWebTokenAuthenticationQueryParam', + 'rest_framework.authentication.SessionAuthentication', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', From b177f75ef15f812d16b1d84ede212e4a6d1f5a74 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 11 Aug 2017 09:54:03 +0200 Subject: [PATCH 131/980] Cleaned up the migrations --- .../migrations/0003_auto_20170808_1023.py | 27 ------------------- ...809_1647.py => 0004_auto_20170811_0952.py} | 11 +++++--- .../migrations/0005_merge_20170810_0924.py | 16 ----------- 3 files changed, 8 insertions(+), 46 deletions(-) delete mode 100644 timed/employment/migrations/0003_auto_20170808_1023.py rename timed/employment/migrations/{0004_auto_20170809_1647.py => 0004_auto_20170811_0952.py} (73%) delete mode 100644 timed/employment/migrations/0005_merge_20170810_0924.py diff --git a/timed/employment/migrations/0003_auto_20170808_1023.py b/timed/employment/migrations/0003_auto_20170808_1023.py deleted file mode 100644 index 887d19e24..000000000 --- a/timed/employment/migrations/0003_auto_20170808_1023.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-08 08:23 -from __future__ import unicode_literals - -from django.db import migrations, models -import timed.employment.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0002_user_supervisors'), - ] - - operations = [ - migrations.AlterModelManagers( - name='user', - managers=[ - ('objects', timed.employment.models.UserManager()), - ], - ), - migrations.AddField( - model_name='absencecredit', - name='comment', - field=models.CharField(blank=True, max_length=255), - ), - ] diff --git a/timed/employment/migrations/0004_auto_20170809_1647.py b/timed/employment/migrations/0004_auto_20170811_0952.py similarity index 73% rename from timed/employment/migrations/0004_auto_20170809_1647.py rename to timed/employment/migrations/0004_auto_20170811_0952.py index f344b82cd..57a9f6c6b 100644 --- a/timed/employment/migrations/0004_auto_20170809_1647.py +++ b/timed/employment/migrations/0004_auto_20170811_0952.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-09 14:47 +# Generated by Django 1.11.4 on 2017-08-11 07:52 from __future__ import unicode_literals from django.db import migrations, models @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ('employment', '0003_auto_20170808_1023'), + ('employment', '0003_auto_20170808_1403'), ] operations = [ @@ -17,8 +17,8 @@ class Migration(migrations.Migration): fields=[ ], options={ - 'indexes': [], 'proxy': True, + 'indexes': [], }, bases=('employment.absencetype',), ), @@ -26,6 +26,11 @@ class Migration(migrations.Migration): model_name='absencecredit', name='duration', ), + migrations.AddField( + model_name='absencecredit', + name='comment', + field=models.CharField(blank=True, max_length=255), + ), migrations.AddField( model_name='absencecredit', name='days', diff --git a/timed/employment/migrations/0005_merge_20170810_0924.py b/timed/employment/migrations/0005_merge_20170810_0924.py deleted file mode 100644 index cfe2b6c8c..000000000 --- a/timed/employment/migrations/0005_merge_20170810_0924.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-10 07:24 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0003_auto_20170808_1403'), - ('employment', '0004_auto_20170809_1647'), - ] - - operations = [ - ] From f2ef904d603eede480e688a2c88591f762f3daae Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 11 Aug 2017 09:57:05 +0200 Subject: [PATCH 132/980] Cleaned up serializer imports --- timed/employment/serializers.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 1e76edf3a..0aa88863c 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -5,8 +5,7 @@ from dateutil import rrule from django.contrib.auth import get_user_model from django.utils.duration import duration_string -from rest_framework_json_api.relations import (ResourceRelatedField, # noqa - SerializerMethodResourceRelatedField) # noqa +from rest_framework_json_api import relations from rest_framework_json_api.serializers import (ModelSerializer, SerializerMethodField) @@ -17,10 +16,10 @@ class UserSerializer(ModelSerializer): """User serializer.""" - employments = ResourceRelatedField(many=True, read_only=True) - worktime_balance = SerializerMethodField() - - user_absence_types = SerializerMethodResourceRelatedField( + employments = relations.ResourceRelatedField(many=True, + read_only=True) + worktime_balance = SerializerMethodField() + user_absence_types = relations.SerializerMethodResourceRelatedField( source='get_user_absence_types', model=models.UserAbsenceType, many=True, @@ -176,8 +175,8 @@ class Meta: class EmploymentSerializer(ModelSerializer): """Employment serializer.""" - user = ResourceRelatedField(read_only=True) - location = ResourceRelatedField(read_only=True) + user = relations.ResourceRelatedField(read_only=True) + location = relations.ResourceRelatedField(read_only=True) included_serializers = { 'user': 'timed.employment.serializers.UserSerializer', @@ -211,7 +210,7 @@ class Meta: class PublicHolidaySerializer(ModelSerializer): """Public holiday serializer.""" - location = ResourceRelatedField(read_only=True) + location = relations.ResourceRelatedField(read_only=True) included_serializers = { 'location': 'timed.employment.serializers.LocationSerializer' @@ -240,14 +239,14 @@ class UserAbsenceTypeSerializer(ModelSerializer): used_days = SerializerMethodField() balance = SerializerMethodField() - absence_credits = SerializerMethodResourceRelatedField( + absence_credits = relations.SerializerMethodResourceRelatedField( source='get_absence_credits', model=models.AbsenceCredit, many=True, read_only=True ) - user = SerializerMethodResourceRelatedField( + user = relations.SerializerMethodResourceRelatedField( source='get_user', model=get_user_model(), read_only=True @@ -396,8 +395,8 @@ class Meta: class AbsenceCreditSerializer(ModelSerializer): """Absence credit serializer.""" - absence_type = ResourceRelatedField(read_only=True) - user = ResourceRelatedField(read_only=True) + absence_type = relations.ResourceRelatedField(read_only=True) + user = relations.ResourceRelatedField(read_only=True) included_serializers = { 'absence_type': 'timed.employment.serializers.AbsenceTypeSerializer' @@ -419,7 +418,7 @@ class Meta: class OvertimeCreditSerializer(ModelSerializer): """Overtime credit serializer.""" - user = ResourceRelatedField(read_only=True) + user = relations.ResourceRelatedField(read_only=True) class Meta: """Meta information for the overtime credit serializer.""" From 26704dad3c8d81eb75fd5181dc91d2f544175219 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 11 Aug 2017 15:44:45 +0200 Subject: [PATCH 133/980] Move calculations into SQL and test them properly --- timed/employment/models.py | 72 ++++++++++++- timed/employment/serializers.py | 158 +++++++++------------------- timed/employment/tests/test_user.py | 52 +++++++-- 3 files changed, 157 insertions(+), 125 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index a3ad034bd..036b5f99d 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -3,6 +3,7 @@ import datetime from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser, UserManager from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -164,7 +165,8 @@ class AbsenceCredit(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='absence_credits') comment = models.CharField(max_length=255, blank=True) - absence_type = models.ForeignKey(AbsenceType) + absence_type = models.ForeignKey(AbsenceType, + related_name='absence_credits') date = models.DateField() days = models.PositiveIntegerField(default=0) @@ -183,10 +185,70 @@ class OvertimeCredit(models.Model): class UserAbsenceTypeManager(models.Manager): - def with_user(self, user): - return UserAbsenceType.objects.all().annotate( - user_id=models.Value(user.id, models.IntegerField()) - ) + def with_user(self, user, start_date, end_date): + from timed.tracking.models import Absence + + return UserAbsenceType.objects.raw(""" + SELECT + at.*, + %(user_id)s AS user_id, + %(start)s AS start_date, + %(end)s AS end_date, + CASE + WHEN at.fill_worktime THEN NULL + ELSE sq1.credit + END AS credit, + CASE + WHEN at.fill_worktime THEN NULL + ELSE sq2.used_days + END AS used_days, + CASE + WHEN at.fill_worktime THEN sq2.used_duration + ELSE NULL + END AS used_duration, + CASE + WHEN at.fill_worktime THEN NULL + ELSE sq1.credit - sq2.used_days + END AS balance + FROM {absencetype_table} AS at + LEFT JOIN ( + SELECT + at.id, + SUM(ac.days) AS credit + FROM {absencetype_table} AS at + LEFT JOIN {absencecredit_table} AS ac ON ( + ac.absence_type_id = at.id + AND + ac.user_id = %(user_id)s + AND + ac.date BETWEEN %(start)s AND %(end)s + ) + GROUP BY at.id, ac.absence_type_id + ) AS sq1 ON (at.id = sq1.id) + LEFT JOIN ( + SELECT + at.id, + COUNT(a.id) AS used_days, + SUM(a.duration) AS used_duration + FROM {absencetype_table} AS at + LEFT JOIN {absence_table} AS a ON ( + a.type_id = at.id + and + a.user_id = %(user_id)s + AND + a.date BETWEEN %(start)s AND %(end)s + ) + GROUP BY at.id, a.type_id + ) AS sq2 ON (at.id = sq2.id) + """.format( + absence_table=Absence._meta.db_table, + absencetype_table=AbsenceType._meta.db_table, + absencecredit_table=AbsenceCredit._meta.db_table + ), { + 'user_id': user.id, + 'start': start_date, + 'end': end_date + }) class UserAbsenceType(AbsenceType): diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 0aa88863c..db2055d10 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -6,7 +6,8 @@ from django.contrib.auth import get_user_model from django.utils.duration import duration_string from rest_framework_json_api import relations -from rest_framework_json_api.serializers import (ModelSerializer, +from rest_framework_json_api.serializers import (DurationField, IntegerField, + ModelSerializer, SerializerMethodField) from timed.employment import models @@ -26,12 +27,41 @@ class UserSerializer(ModelSerializer): read_only=True ) + def get_dates(self, instance): + """Get the start and end time for the credits. + + :returns: A tuple of dates + """ + request = self.context.get('request') + + end = datetime.strptime( + request.query_params.get( + 'until', + date.today().strftime('%Y-%m-%d') + ), + '%Y-%m-%d' + ).date() + + user = get_user_model().objects.get(pk=instance.id) + + employment = models.Employment.objects.for_user(user, end) + + start = max( + employment.start_date, date(date.today().year, 1, 1) + ) + + return (start, end) + def get_user_absence_types(self, instance): """Get the user absence types for this user. :returns: All absence types for this user """ - return models.UserAbsenceType.objects.with_user(instance) + start_date, end_date = self.get_dates(instance) + + return models.UserAbsenceType.objects.with_user(instance, + start_date, + end_date) def get_worktime(self, user, start=None, end=None): """Calculate the reported, expected and balance for user. @@ -234,10 +264,16 @@ class UserAbsenceTypeSerializer(ModelSerializer): and balances. """ - credit = SerializerMethodField() - used_duration = SerializerMethodField() - used_days = SerializerMethodField() - balance = SerializerMethodField() + credit = IntegerField() + used_days = IntegerField() + used_duration = DurationField() + balance = IntegerField() + + user = relations.SerializerMethodResourceRelatedField( + source='get_user', + model=get_user_model(), + read_only=True + ) absence_credits = relations.SerializerMethodResourceRelatedField( source='get_absence_credits', @@ -246,118 +282,18 @@ class UserAbsenceTypeSerializer(ModelSerializer): read_only=True ) - user = relations.SerializerMethodResourceRelatedField( - source='get_user', - model=get_user_model(), - read_only=True - ) + def get_user(self, instance): + return get_user_model().objects.get(pk=instance.user_id) def get_absence_credits(self, instance): """Get the absence credits for the user and type.""" return models.AbsenceCredit.objects.filter( absence_type=instance, - user_id=instance.user_id - ) - - def get_user(self, instance): - """Get the user of this user absence type.""" - return get_user_model().objects.get(pk=instance.user_id) - - def get_dates(self, instance): - """Get the start and end time for the credits. - - :returns: A tuple of dates - """ - request = self.context.get('request') - - end = datetime.strptime( - request.query_params.get( - 'until', - date.today().strftime('%Y-%m-%d') - ), - '%Y-%m-%d' - ) - - user = get_user_model().objects.get(pk=instance.user_id) - - employment = models.Employment.objects.for_user(user, end) - - start = max( - employment.start_date, date(date.today().year, 1, 1) + user__id=instance.user_id, + date__gte=instance.start_date, + date__lte=instance.end_date ) - return (start, end) - - def get_credit(self, instance): - """Calculate the total credited days. - - :return: The total credited days - :rtype: int - """ - if instance.fill_worktime: - return None - - start, end = self.get_dates(instance) - - return sum(models.AbsenceCredit.objects.filter( - user_id=instance.user_id, - absence_type=instance, - date__gte=start, - date__lte=end - ).values_list('days', flat=True)) - - def get_used_days(self, instance): - """Calculate the total used days. - - :return: The total used days - :rtype: int - """ - if instance.fill_worktime: - return None - - start, end = self.get_dates(instance) - - return len(Absence.objects.filter( - user_id=instance.user_id, - type=instance, - date__gte=start, - date__lte=end - )) - - def get_used_duration(self, instance): - """Calculate the total used duration. - - This is only calculated for types which fill the worktime such as - sickness. - - :return: The total used duration - :rtype: str - """ - if not instance.fill_worktime: - return None - - start, end = self.get_dates(instance) - - return duration_string(sum(Absence.objects.filter( - user_id=instance.user_id, - type=instance, - date__gte=start, - date__lte=end - ).values_list('duration', flat=True), timedelta())) - - def get_balance(self, instance): - """Calculate the balance of credited and used days. - - :return: The balance - :rtype: int - """ - credit = self.get_credit(instance) - - if instance.fill_worktime or credit is None: - return None - - return credit - self.get_used_days(instance) - included_serializers = { 'absence_credits': 'timed.employment.serializers.AbsenceCreditSerializer', diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 780abb618..bfdca584c 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -216,7 +216,20 @@ def test_user_without_employment(self): ) def test_user_absence_types(self): - credits = AbsenceCreditFactory.create_batch(3, user=self.user) + absence_type = AbsenceTypeFactory.create() + + credit = AbsenceCreditFactory.create(date=date.today(), + user=self.user, + days=5, + absence_type=absence_type) + + AbsenceFactory.create(date=date.today(), + user=self.user, + type=absence_type) + + AbsenceFactory.create(date=date.today() - timedelta(days=1), + user=self.user, + type=absence_type) url = reverse('user-detail', args=[ self.user.id @@ -231,22 +244,43 @@ def test_user_absence_types(self): rel = result['data']['relationships'] inc = result['included'] - assert len(rel['user-absence-types']['data']) == len(credits) - assert len(inc) == len(credits) + assert len(rel['user-absence-types']['data']) == 1 + assert len(inc) == 1 assert ( inc[0]['id'] == - '{0}-{1}'.format(self.user.id, credits[0].absence_type.id) + '{0}-{1}'.format(self.user.id, credit.absence_type.id) ) - assert type(inc[0]['attributes']['credit']) == int - assert type(inc[0]['attributes']['used-days']) == int - assert type(inc[0]['attributes']['balance']) == int + assert inc[0]['attributes']['credit'] == 5 + assert inc[0]['attributes']['balance'] == 3 + assert inc[0]['attributes']['used-days'] == 2 assert inc[0]['attributes']['used-duration'] is None def test_user_absence_types_fill_worktime(self): absence_type = AbsenceTypeFactory.create(fill_worktime=True) + employment = self.user.employments.get(end_date__isnull=True) + + employment.worktime_per_day = timedelta(hours=5) + employment.start_date = date.today() - timedelta(days=1) + + employment.save() + + ReportFactory.create( + user=self.user, + date=date.today(), + duration=timedelta(hours=4) + ) + + AbsenceFactory.create(date=date.today(), + user=self.user, + type=absence_type) + + AbsenceFactory.create(date=date.today() - timedelta(days=1), + user=self.user, + type=absence_type) + url = reverse('user-detail', args=[ self.user.id ]) @@ -269,6 +303,6 @@ def test_user_absence_types_fill_worktime(self): ) assert inc[0]['attributes']['credit'] is None - assert inc[0]['attributes']['used-days'] is None assert inc[0]['attributes']['balance'] is None - assert type(inc[0]['attributes']['used-duration']) == str + assert inc[0]['attributes']['used-days'] is None + assert inc[0]['attributes']['used-duration'] == '06:00:00' From 8a17628d23bbd38d0b4f2b04a2bf3ee04a20f4b7 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 11 Aug 2017 15:51:39 +0200 Subject: [PATCH 134/980] Added missing migration --- .../migrations/0005_auto_20170811_1550.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 timed/employment/migrations/0005_auto_20170811_1550.py diff --git a/timed/employment/migrations/0005_auto_20170811_1550.py b/timed/employment/migrations/0005_auto_20170811_1550.py new file mode 100644 index 000000000..cd81a642f --- /dev/null +++ b/timed/employment/migrations/0005_auto_20170811_1550.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-11 13:50 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0004_auto_20170811_0952'), + ] + + operations = [ + migrations.AlterField( + model_name='absencecredit', + name='absence_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to='employment.AbsenceType'), + ), + ] From 20f2d1f8a169905edde94175a1fc5ba99d662592 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 11 Aug 2017 16:06:25 +0200 Subject: [PATCH 135/980] Removed useless import --- timed/employment/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 036b5f99d..e0f5ef114 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -3,7 +3,6 @@ import datetime from django.conf import settings -from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser, UserManager from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models From cc2991ec4a8f596d306f6a4eb3fc403dc41e7d19 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 11 Aug 2017 17:01:20 +0200 Subject: [PATCH 136/980] Fixed tests --- timed/employment/serializers.py | 39 +++++++++++------------------ timed/employment/tests/test_user.py | 4 +-- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index db2055d10..232bf5479 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -27,10 +27,10 @@ class UserSerializer(ModelSerializer): read_only=True ) - def get_dates(self, instance): - """Get the start and end time for the credits. + def get_user_absence_types(self, instance): + """Get the user absence types for this user. - :returns: A tuple of dates + :returns: All absence types for this user """ request = self.context.get('request') @@ -42,26 +42,18 @@ def get_dates(self, instance): '%Y-%m-%d' ).date() - user = get_user_model().objects.get(pk=instance.id) - - employment = models.Employment.objects.for_user(user, end) + try: + employment = models.Employment.objects.for_user(instance, end) + except models.Employment.DoesNotExist: + return models.UserAbsenceType.objects.none() start = max( employment.start_date, date(date.today().year, 1, 1) ) - return (start, end) - - def get_user_absence_types(self, instance): - """Get the user absence types for this user. - - :returns: All absence types for this user - """ - start_date, end_date = self.get_dates(instance) - return models.UserAbsenceType.objects.with_user(instance, - start_date, - end_date) + start, + end) def get_worktime(self, user, start=None, end=None): """Calculate the reported, expected and balance for user. @@ -93,13 +85,12 @@ def get_worktime(self, user, start=None, end=None): :returns: tuple of 3 values reported, expected and balance in given time frame """ - employment = models.Employment.objects.filter( - user=user, - end_date__isnull=True - ).first() + end = end or date.today() - # If there is no active employment, set the balance to 0 - if employment is None: + try: + employment = models.Employment.objects.for_user(user, end) + except models.Employment.DoesNotExist: + # If there is no active employment, set the balance to 0 return timedelta(), timedelta(), timedelta() location = employment.location @@ -109,8 +100,6 @@ def get_worktime(self, user, start=None, end=None): employment.start_date, date(date.today().year, 1, 1) ) - end = end or date.today() - # workdays is in isoweekday, byweekday expects Monday to be zero week_workdays = [int(day) - 1 for day in employment.location.workdays] workdays = rrule.rrule( diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index bfdca584c..f8428d55b 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -235,7 +235,7 @@ def test_user_absence_types(self): self.user.id ]) - res = self.client.get('{0}?include=user_absence_types'.format(url)) + res = self.client.get(url, {'include': 'user_absence_types'}) assert res.status_code == HTTP_200_OK @@ -285,7 +285,7 @@ def test_user_absence_types_fill_worktime(self): self.user.id ]) - res = self.client.get('{0}?include=user_absence_types'.format(url)) + res = self.client.get(url, {'include': 'user_absence_types'}) assert res.status_code == HTTP_200_OK From c116ca0e203b98af8f846e104a3fa07036781ed1 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 14 Aug 2017 12:13:38 +0200 Subject: [PATCH 137/980] Added docstring for with_user function --- timed/employment/models.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index e0f5ef114..2ffa69fbd 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -185,6 +185,19 @@ class OvertimeCredit(models.Model): class UserAbsenceTypeManager(models.Manager): def with_user(self, user, start_date, end_date): + """Get all user absence types with the needed calculations. + + This is achieved using a raw query because the calculations were too + complicated to do with django annotations / aggregations. Since those + proxy models are read only and don't need to be filtered or anything, + the raw query shouldn't block any needed functions. + + :param User user: The user of the user absence type + :param datetime.date start_date: Start date of the user absence type + :param datetime.date end_date: End date of the user absence type + :returns: User absence types for the requested user + :rtype: django.db.models.QuerySet + """ from timed.tracking.models import Absence return UserAbsenceType.objects.raw(""" @@ -195,19 +208,19 @@ def with_user(self, user, start_date, end_date): %(end)s AS end_date, CASE WHEN at.fill_worktime THEN NULL - ELSE sq1.credit + ELSE credit_join.credit END AS credit, CASE WHEN at.fill_worktime THEN NULL - ELSE sq2.used_days + ELSE used_join.used_days END AS used_days, CASE - WHEN at.fill_worktime THEN sq2.used_duration + WHEN at.fill_worktime THEN used_join.used_duration ELSE NULL END AS used_duration, CASE WHEN at.fill_worktime THEN NULL - ELSE sq1.credit - sq2.used_days + ELSE credit_join.credit - used_join.used_days END AS balance FROM {absencetype_table} AS at LEFT JOIN ( @@ -223,7 +236,7 @@ def with_user(self, user, start_date, end_date): ac.date BETWEEN %(start)s AND %(end)s ) GROUP BY at.id, ac.absence_type_id - ) AS sq1 ON (at.id = sq1.id) + ) AS credit_join ON (at.id = credit_join.id) LEFT JOIN ( SELECT at.id, @@ -238,7 +251,7 @@ def with_user(self, user, start_date, end_date): a.date BETWEEN %(start)s AND %(end)s ) GROUP BY at.id, a.type_id - ) AS sq2 ON (at.id = sq2.id) + ) AS used_join ON (at.id = used_join.id) """.format( absence_table=Absence._meta.db_table, absencetype_table=AbsenceType._meta.db_table, From e042d782ca42e4c4406512f8a7daf2d6bbb610e0 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 14 Aug 2017 15:27:28 +0200 Subject: [PATCH 138/980] Replaced activity 'start_datetime' with 'date' --- timed/tracking/filters.py | 23 +--------- .../migrations/0003_auto_20170814_1509.py | 44 +++++++++++++++++++ timed/tracking/models.py | 18 ++++---- timed/tracking/serializers.py | 2 +- timed/tracking/tests/test_activity.py | 2 +- 5 files changed, 57 insertions(+), 32 deletions(-) create mode 100644 timed/tracking/migrations/0003_auto_20170814_1509.py diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index d995551f0..f31bf6d08 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -1,6 +1,5 @@ """Filters for filtering the data of the tracking app endpoints.""" -import datetime from functools import wraps from django_filters import DateFilter, Filter, FilterSet, NumberFilter @@ -31,24 +30,6 @@ def wrapper(self, qs, value): return wrapper -class DayFilter(Filter): - """Filter to filter a queryset by day.""" - - def filter(self, qs, value): - """Filter the queryset. - - :param QuerySet qs: The queryset to filter - :param str value: The day to filter to - :return: The filtered queryset - :rtype: QuerySet - """ - date = datetime.datetime.strptime(value, '%Y-%m-%d').date() - - return qs.filter(**{ - '%s__date' % self.name: date - }) - - class ActivityActiveFilter(Filter): """Filter to filter activities by being currently active or not. @@ -75,7 +56,7 @@ class ActivityFilterSet(FilterSet): """Filter set for the activities endpoint.""" active = ActivityActiveFilter() - day = DayFilter(name='start_datetime') + day = DateFilter(name='date') class Meta: """Meta information for the activity filter set.""" @@ -97,7 +78,7 @@ class Meta: class AttendanceFilterSet(FilterSet): """Filter set for the attendance endpoint.""" - day = DayFilter(name='from_datetime') + day = DateFilter(name='from_datetime', lookup_expr='date') class Meta: """Meta information for the attendance filter set.""" diff --git a/timed/tracking/migrations/0003_auto_20170814_1509.py b/timed/tracking/migrations/0003_auto_20170814_1509.py new file mode 100644 index 000000000..cb44678db --- /dev/null +++ b/timed/tracking/migrations/0003_auto_20170814_1509.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-14 13:09 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +def migrate_start_datetime(apps, schema_editor): + Activity = apps.get_model('tracking', 'Activity') + + for a in Activity.objects.all(): + a.date = a.start_datetime.date() + a.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0002_auto_20170731_1047'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='date', + field=models.DateField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.RunSQL('SET CONSTRAINTS ALL IMMEDIATE', reverse_sql=migrations.RunSQL.noop), + migrations.RunPython(migrate_start_datetime), + migrations.RunSQL(migrations.RunSQL.noop, reverse_sql='SET CONSTRAINTS ALL IMMEDIATE'), + migrations.RemoveIndex( + model_name='activity', + name='tracking_ac_start_d_c43640_idx', + ), + migrations.RemoveField( + model_name='activity', + name='start_datetime', + ), + migrations.AddIndex( + model_name='activity', + index=models.Index(fields=['date'], name='tracking_ac_date_bf4ad0_idx'), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 0cbade076..bd748d30d 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -16,14 +16,14 @@ class Activity(models.Model): certain task. """ - comment = models.TextField(blank=True) - start_datetime = models.DateTimeField(auto_now_add=True) - task = models.ForeignKey('projects.Task', - null=True, - blank=True, - related_name='activities') - user = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name='activities') + comment = models.TextField(blank=True) + date = models.DateField(auto_now_add=True) + task = models.ForeignKey('projects.Task', + null=True, + blank=True, + related_name='activities') + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='activities') @property def duration(self): @@ -48,7 +48,7 @@ class Meta: """Meta informations for the activity model.""" verbose_name_plural = 'activities' - indexes = [models.Index(fields=['start_datetime'])] + indexes = [models.Index(fields=['date'])] class ActivityBlock(models.Model): diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 0782ca075..7d2b20075 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -34,7 +34,7 @@ class Meta: model = models.Activity fields = [ 'comment', - 'start_datetime', + 'date', 'duration', 'user', 'task', diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 5dad2b896..f97eed504 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -170,7 +170,7 @@ def test_activity_list_filter_day(self): now = datetime.now(timezone('Europe/Zurich')) activity = self.activities[0] - activity.start_datetime = now + activity.date = now activity.save() url = reverse('activity-list') From 433fd77351ce524cbc0e1dc972d7b117d135b4b8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 14 Aug 2017 17:32:58 +0200 Subject: [PATCH 139/980] Integrate tasks into project admin --- timed/projects/admin.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index f15fcec38..90fb9d8df 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -13,20 +13,20 @@ class CustomerAdmin(admin.ModelAdmin): search_fields = ['name'] +class TaskInline(admin.TabularInline): + model = models.Task + extra = 0 + + @admin.register(models.Project) class ProjectAdmin(admin.ModelAdmin): """Project admin view.""" list_display = ['name', 'customer'] list_filter = ['customer'] - search_fields = ['name', 'customer'] - - -@admin.register(models.Task) -class TaskAdmin(admin.ModelAdmin): - """Task admin view.""" + search_fields = ['name', 'customer__name'] - list_display = ['__str__'] + inlines = [TaskInline] @admin.register(models.TaskTemplate) From 7cbe1dff07520668bdb96aee28b38f1d586cc4ae Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 15 Aug 2017 08:40:25 +0200 Subject: [PATCH 140/980] Add template tasks to project per default. When a new project gets added all tasks in task template will be added per default. Such can be removed later on when needed. --- timed/projects/admin.py | 19 ++++++++++++++++++- timed/projects/models.py | 13 ------------- timed/projects/tests/test_project.py | 11 +---------- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 90fb9d8df..ed3dfc92a 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -1,6 +1,7 @@ """Views for the admin interface.""" from django.contrib import admin +from django.forms.models import BaseInlineFormSet from timed.projects import models @@ -13,9 +14,25 @@ class CustomerAdmin(admin.ModelAdmin): search_fields = ['name'] +class TaskInlineFormset(BaseInlineFormSet): + """Task formset defaulting to task templates when project is created.""" + + def __init__(self, *args, **kwargs): + kwargs['initial'] = [ + {'name': tmpl.name} + for tmpl in models.TaskTemplate.objects.order_by('name') + ] + super().__init__(*args, **kwargs) + + class TaskInline(admin.TabularInline): + formset = TaskInlineFormset model = models.Task - extra = 0 + + def get_extra(self, request, obj=None, **kwargs): + if obj is not None: + return 0 + return models.TaskTemplate.objects.count() @admin.register(models.Project) diff --git a/timed/projects/models.py b/timed/projects/models.py index 71863a271..2220bc5b4 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -1,8 +1,6 @@ """Models for the projects app.""" from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver class Customer(models.Model): @@ -108,14 +106,3 @@ def __str__(self): :rtype: str """ return self.name - - -@receiver(post_save, sender=Project) -def create_default_tasks(sender, instance, created, **kwargs): - """Create default tasks on a project. - - This gets executed as soon as a project is created. - """ - if created: - for template in TaskTemplate.objects.all(): - Task.objects.create(name=template.name, project=instance) diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index d2d504128..947d2ab93 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -5,8 +5,7 @@ HTTP_405_METHOD_NOT_ALLOWED) from timed.jsonapi_test_case import JSONAPITestCase -from timed.projects.factories import ProjectFactory, TaskTemplateFactory -from timed.projects.models import Task +from timed.projects.factories import ProjectFactory class ProjectTests(JSONAPITestCase): @@ -91,11 +90,3 @@ def test_project_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_project_default_tasks(self): - """Should generate tasks based on task templates for a new project.""" - templates = TaskTemplateFactory.create_batch(5) - project = ProjectFactory.create() - tasks = Task.objects.filter(project=project) - - assert len(templates) == len(tasks) From 22a2989ba9f48451c15fee8b8b5dcedad209eb3d Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Tue, 15 Aug 2017 13:13:07 +0200 Subject: [PATCH 141/980] Reset migrations --- timed/employment/migrations/0001_initial.py | 26 ++++++++--- .../migrations/0002_user_supervisors.py | 21 --------- .../migrations/0003_auto_20170808_1403.py | 22 ---------- .../migrations/0004_auto_20170811_0952.py | 39 ---------------- .../migrations/0005_auto_20170811_1550.py | 21 --------- timed/projects/migrations/0001_initial.py | 2 +- timed/tracking/migrations/0001_initial.py | 10 +++-- .../migrations/0002_auto_20170731_1047.py | 27 ------------ .../migrations/0003_auto_20170814_1509.py | 44 ------------------- 9 files changed, 26 insertions(+), 186 deletions(-) delete mode 100644 timed/employment/migrations/0002_user_supervisors.py delete mode 100644 timed/employment/migrations/0003_auto_20170808_1403.py delete mode 100644 timed/employment/migrations/0004_auto_20170811_0952.py delete mode 100644 timed/employment/migrations/0005_auto_20170811_1550.py delete mode 100644 timed/tracking/migrations/0002_auto_20170731_1047.py delete mode 100644 timed/tracking/migrations/0003_auto_20170814_1509.py diff --git a/timed/employment/migrations/0001_initial.py b/timed/employment/migrations/0001_initial.py index 8c662cd49..71930004c 100644 --- a/timed/employment/migrations/0001_initial.py +++ b/timed/employment/migrations/0001_initial.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-27 11:18 +# Generated by Django 1.11.4 on 2017-08-15 11:09 from __future__ import unicode_literals from django.conf import settings -import django.contrib.auth.models import django.contrib.auth.validators import django.core.validators from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +import timed.employment.models import timed.models @@ -36,23 +36,25 @@ class Migration(migrations.Migration): ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('supervisors', models.ManyToManyField(related_name='supervisees', to=settings.AUTH_USER_MODEL)), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), ], options={ - 'verbose_name_plural': 'users', - 'verbose_name': 'user', 'abstract': False, + 'verbose_name': 'user', + 'verbose_name_plural': 'users', }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ('objects', timed.employment.models.UserManager()), ], ), migrations.CreateModel( name='AbsenceCredit', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.CharField(blank=True, max_length=255)), ('date', models.DateField()), - ('duration', models.DurationField(blank=True, null=True)), + ('days', models.PositiveIntegerField(default=0)), ], ), migrations.CreateModel( @@ -112,13 +114,23 @@ class Migration(migrations.Migration): migrations.AddField( model_name='absencecredit', name='absence_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.AbsenceType'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to='employment.AbsenceType'), ), migrations.AddField( model_name='absencecredit', name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to=settings.AUTH_USER_MODEL), ), + migrations.CreateModel( + name='UserAbsenceType', + fields=[ + ], + options={ + 'indexes': [], + 'proxy': True, + }, + bases=('employment.absencetype',), + ), migrations.AddIndex( model_name='publicholiday', index=models.Index(fields=['date'], name='employment__date_2d002c_idx'), diff --git a/timed/employment/migrations/0002_user_supervisors.py b/timed/employment/migrations/0002_user_supervisors.py deleted file mode 100644 index eced1e2ec..000000000 --- a/timed/employment/migrations/0002_user_supervisors.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-27 13:42 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='supervisors', - field=models.ManyToManyField(related_name='supervisees', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/timed/employment/migrations/0003_auto_20170808_1403.py b/timed/employment/migrations/0003_auto_20170808_1403.py deleted file mode 100644 index fbeb1312b..000000000 --- a/timed/employment/migrations/0003_auto_20170808_1403.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-08 12:03 -from __future__ import unicode_literals - -from django.db import migrations -import timed.employment.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0002_user_supervisors'), - ] - - operations = [ - migrations.AlterModelManagers( - name='user', - managers=[ - ('objects', timed.employment.models.UserManager()), - ], - ), - ] diff --git a/timed/employment/migrations/0004_auto_20170811_0952.py b/timed/employment/migrations/0004_auto_20170811_0952.py deleted file mode 100644 index 57a9f6c6b..000000000 --- a/timed/employment/migrations/0004_auto_20170811_0952.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-11 07:52 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0003_auto_20170808_1403'), - ] - - operations = [ - migrations.CreateModel( - name='UserAbsenceType', - fields=[ - ], - options={ - 'proxy': True, - 'indexes': [], - }, - bases=('employment.absencetype',), - ), - migrations.RemoveField( - model_name='absencecredit', - name='duration', - ), - migrations.AddField( - model_name='absencecredit', - name='comment', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='absencecredit', - name='days', - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/timed/employment/migrations/0005_auto_20170811_1550.py b/timed/employment/migrations/0005_auto_20170811_1550.py deleted file mode 100644 index cd81a642f..000000000 --- a/timed/employment/migrations/0005_auto_20170811_1550.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-11 13:50 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('employment', '0004_auto_20170811_0952'), - ] - - operations = [ - migrations.AlterField( - model_name='absencecredit', - name='absence_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to='employment.AbsenceType'), - ), - ] diff --git a/timed/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py index 0ff9f66f5..0d97e7898 100644 --- a/timed/projects/migrations/0001_initial.py +++ b/timed/projects/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-27 11:18 +# Generated by Django 1.11.4 on 2017-08-15 11:09 from __future__ import unicode_literals from django.db import migrations, models diff --git a/timed/tracking/migrations/0001_initial.py b/timed/tracking/migrations/0001_initial.py index 815153fbc..c6db2f4ff 100644 --- a/timed/tracking/migrations/0001_initial.py +++ b/timed/tracking/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-27 11:18 +# Generated by Django 1.11.4 on 2017-08-15 11:09 from __future__ import unicode_literals import datetime @@ -13,9 +13,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('employment', '0001_initial'), ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -35,7 +35,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('comment', models.TextField(blank=True)), - ('start_datetime', models.DateTimeField(auto_now_add=True)), + ('date', models.DateField(auto_now_add=True)), ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='projects.Task')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL)), ], @@ -70,6 +70,8 @@ class Migration(migrations.Migration): ('duration', models.DurationField()), ('review', models.BooleanField(default=False)), ('not_billable', models.BooleanField(default=False)), + ('added', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), ('activity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='tracking.Activity')), ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='projects.Task')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL)), @@ -81,7 +83,7 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name='activity', - index=models.Index(fields=['start_datetime'], name='tracking_ac_start_d_c43640_idx'), + index=models.Index(fields=['date'], name='tracking_ac_date_bf4ad0_idx'), ), migrations.AlterUniqueTogether( name='absence', diff --git a/timed/tracking/migrations/0002_auto_20170731_1047.py b/timed/tracking/migrations/0002_auto_20170731_1047.py deleted file mode 100644 index a26c47003..000000000 --- a/timed/tracking/migrations/0002_auto_20170731_1047.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-31 08:47 -from __future__ import unicode_literals - -from django.db import migrations, models -from django.utils import timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='report', - name='added', - field=models.DateTimeField(auto_now_add=True, default=timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='report', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/timed/tracking/migrations/0003_auto_20170814_1509.py b/timed/tracking/migrations/0003_auto_20170814_1509.py deleted file mode 100644 index cb44678db..000000000 --- a/timed/tracking/migrations/0003_auto_20170814_1509.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-14 13:09 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.utils.timezone - - -def migrate_start_datetime(apps, schema_editor): - Activity = apps.get_model('tracking', 'Activity') - - for a in Activity.objects.all(): - a.date = a.start_datetime.date() - a.save() - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0002_auto_20170731_1047'), - ] - - operations = [ - migrations.AddField( - model_name='activity', - name='date', - field=models.DateField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.RunSQL('SET CONSTRAINTS ALL IMMEDIATE', reverse_sql=migrations.RunSQL.noop), - migrations.RunPython(migrate_start_datetime), - migrations.RunSQL(migrations.RunSQL.noop, reverse_sql='SET CONSTRAINTS ALL IMMEDIATE'), - migrations.RemoveIndex( - model_name='activity', - name='tracking_ac_start_d_c43640_idx', - ), - migrations.RemoveField( - model_name='activity', - name='start_datetime', - ), - migrations.AddIndex( - model_name='activity', - index=models.Index(fields=['date'], name='tracking_ac_date_bf4ad0_idx'), - ), - ] From b0334659aaa2b6bd733f5d45d48ca0bf22ca46a4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 09:49:37 +0200 Subject: [PATCH 142/980] Per default end points return archived objects Filters are added to Customer, Project, Task and User end point to be able to filter archived and non-archived objects. --- timed/employment/filters.py | 6 ++++++ timed/projects/tests/test_customer.py | 2 +- timed/projects/tests/test_project.py | 2 +- timed/projects/tests/test_task.py | 2 +- timed/projects/views.py | 6 ------ 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index e9bc487d6..dcf2b4b99 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -34,3 +34,9 @@ class Meta: model = models.PublicHoliday fields = ['year', 'location', 'date', 'from_date', 'to_date'] + + +class UserFilterSet(FilterSet): + class Meta: + model = models.User + fields = ['is_active'] diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index ccb89b8c5..142661a65 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -30,7 +30,7 @@ def test_customer_list(self): url = reverse('customer-list') noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + res = self.client.get(url, data={'archived': False}) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_200_OK diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 947d2ab93..322e4bed9 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -30,7 +30,7 @@ def test_project_list(self): url = reverse('project-list') noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + res = self.client.get(url, data={'archived': False}) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_200_OK diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index e9eef1a58..c442492ae 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -29,7 +29,7 @@ def test_task_list(self): url = reverse('task-list') noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + res = self.client.get(url, data={'archived': False}) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_200_OK diff --git a/timed/projects/views.py b/timed/projects/views.py index 5195901b7..0de82a925 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -20,8 +20,6 @@ def get_queryset(self): """ return models.Customer.objects.prefetch_related( 'projects' - ).filter( - archived=False ) @@ -40,8 +38,6 @@ def get_queryset(self): """ return models.Project.objects.select_related( 'customer' - ).filter( - archived=False ) @@ -60,8 +56,6 @@ def get_queryset(self): """ return models.Task.objects.select_related( 'project' - ).filter( - archived=False ) def filter_queryset(self, queryset): From f4468fb3c3bd9249b2de119e4272f5dee470419e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 15 Aug 2017 16:11:32 +0200 Subject: [PATCH 143/980] Add verification logic to report end point This includes: * verified_by field on report * filters for not_verified on reports endpoint * action to verify list or detail of reports --- timed/tracking/filters.py | 16 ++++++++-- .../migrations/0002_report_verified_by.py | 23 ++++++++++++++ timed/tracking/models.py | 3 ++ timed/tracking/serializers.py | 5 ++- timed/tracking/tests/test_report.py | 27 ++++++++++++++++ timed/tracking/views.py | 31 ++++++++++++++++++- 6 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 timed/tracking/migrations/0002_report_verified_by.py diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index f31bf6d08..c60fd90d6 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -2,7 +2,8 @@ from functools import wraps -from django_filters import DateFilter, Filter, FilterSet, NumberFilter +from django_filters import (BooleanFilter, DateFilter, Filter, FilterSet, + NumberFilter) from timed.tracking import models @@ -94,12 +95,23 @@ class ReportFilterSet(FilterSet): to_date = DateFilter(name='date', lookup_expr='lte') project = NumberFilter(name='task__project') customer = NumberFilter(name='task__project__customer') + not_verified = BooleanFilter(name='verified_by', lookup_expr='isnull') class Meta: """Meta information for the report filter set.""" model = models.Report - fields = ['date', 'from_date', 'to_date', 'user', 'task', 'project'] + fields = ( + 'date', + 'from_date', + 'to_date', + 'user', + 'task', + 'project', + 'not_verified', + 'not_billable', + 'review', + ) class AbsenceFilterSet(FilterSet): diff --git a/timed/tracking/migrations/0002_report_verified_by.py b/timed/tracking/migrations/0002_report_verified_by.py new file mode 100644 index 000000000..292176ffb --- /dev/null +++ b/timed/tracking/migrations/0002_report_verified_by.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-15 14:09 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tracking', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='report', + name='verified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index bd748d30d..d3ce9ccaf 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -119,6 +119,9 @@ class Report(models.Model): related_name='reports') user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='reports') + verified_by = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, blank=True) added = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 7d2b20075..70301cb91 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -115,10 +115,12 @@ class ReportSerializer(ModelSerializer): required=False) user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) + verified_by = ResourceRelatedField(read_only=True) included_serializers = { 'task': 'timed.projects.serializers.TaskSerializer', - 'user': 'timed.employment.serializers.UserSerializer' + 'user': 'timed.employment.serializers.UserSerializer', + 'verified_by': 'timed.employment.serializers.UserSerializer' } def validate_date(self, value): @@ -151,6 +153,7 @@ class Meta: 'duration', 'review', 'not_billable', + 'verified_by', 'task', 'activity', 'user', diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 8ff9d11de..2f50f9083 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -60,6 +60,33 @@ def test_report_list(self): assert len(result['data']) == 1 assert result['data'][0]['id'] == str(self.reports[0].id) + def test_report_list_verify(self): + url_list = reverse('report-list') + res = self.client.get(url_list, data={'not_verified': True}) + assert res.status_code == HTTP_200_OK + result = self.result(res) + assert len(result['data']) == 20 + + url_verify = reverse('report-verify') + res = self.client.post(url_verify, QUERY_STRING='user=%s' % + self.user.id) + assert res.status_code == HTTP_200_OK + + res = self.client.get(url_list, data={'not_verified': False}) + assert res.status_code == HTTP_200_OK + result = self.result(res) + assert len(result['data']) == 10 + + def test_report_detail_verify(self): + report = self.reports[0] + url = reverse('report-verify', args=[report.id]) + res = self.client.post(url) + + assert res.status_code == HTTP_200_OK + reports = Report.objects.filter(verified_by=self.user) + assert reports.count() == 1 + assert reports.first().id == report.id + def test_report_export_missing_type(self): """Should respond with a list of filtered reports.""" url = reverse('report-export') diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 405f35c13..7de491f98 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -2,8 +2,9 @@ import django_excel from django.http import HttpResponseBadRequest -from rest_framework.decorators import list_route +from rest_framework.decorators import detail_route, list_route from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from timed.tracking import filters, models, serializers @@ -121,6 +122,34 @@ def export(self, request): sheet, file_type=file_type, file_name='report.%s' % file_type ) + @list_route(methods=['post'], url_path='verify') + def verify_list(self, request): + """ + Verify all reports by given filter. + + Authenticated user will be set as verified_by on given + reports. + """ + queryset = self.filter_queryset(self.get_queryset()) + queryset = self.paginate_queryset(queryset) or queryset + queryset.update(verified_by=request.user) + + return Response(data={}) + + @detail_route(methods=['post'], url_path='verify') + def verify_detail(self, request, pk=None): + """ + Verify given report. + + Authenticated user will be set as verified_by on given + report. + """ + report = self.get_object() + report.verified_by = request.user + report.save() + + return Response(data={}) + def get_queryset(self): """Select related to reduce queries. From 23c0d935f6efc98e03b5bf4a28e73d8d92d44ef1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 11:20:07 +0200 Subject: [PATCH 144/980] Add support for verifing page --- timed/tracking/tests/test_report.py | 12 ++++++++++++ timed/tracking/views.py | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 2f50f9083..9796fdb29 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -77,6 +77,18 @@ def test_report_list_verify(self): result = self.result(res) assert len(result['data']) == 10 + def test_report_list_verify_page(self): + url_verify = reverse('report-verify') + res = self.client.post(url_verify, QUERY_STRING='user=%s&page_size=5' % + self.user.id) + assert res.status_code == HTTP_200_OK + + url_list = reverse('report-list') + res = self.client.get(url_list, data={'not_verified': False}) + assert res.status_code == HTTP_200_OK + result = self.result(res) + assert len(result['data']) == 5 + def test_report_detail_verify(self): report = self.reports[0] url = reverse('report-verify', args=[report.id]) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 7de491f98..25473b781 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -131,7 +131,11 @@ def verify_list(self, request): reports. """ queryset = self.filter_queryset(self.get_queryset()) - queryset = self.paginate_queryset(queryset) or queryset + page = self.paginate_queryset(queryset) + if page is not None: + # page is a list so need to convert it to queryset + ids = [report.id for report in page] + queryset = models.Report.objects.filter(id__in=ids) queryset.update(verified_by=request.user) return Response(data={}) From 2c34712dc480a58104a78ad0ade7c82277f0b5e8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 09:35:11 +0200 Subject: [PATCH 145/980] Add reviewers to project Needed for process of verifying reports before they can be billed. --- timed/projects/admin.py | 11 +++++++++- .../migrations/0002_project_reviewers.py | 22 +++++++++++++++++++ timed/projects/models.py | 3 +++ timed/tracking/filters.py | 2 ++ timed/tracking/tests/test_report.py | 16 +++++++++++++- 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 timed/projects/migrations/0002_project_reviewers.py diff --git a/timed/projects/admin.py b/timed/projects/admin.py index ed3dfc92a..52fa302d9 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from django.forms.models import BaseInlineFormSet +from django.utils.translation import ugettext_lazy as _ from timed.projects import models @@ -35,6 +36,13 @@ def get_extra(self, request, obj=None, **kwargs): return models.TaskTemplate.objects.count() +class ReviewerInline(admin.TabularInline): + model = models.Project.reviewers.through + extra = 0 + verbose_name = _('Reviewer') + verbose_name_plural = _('Reviewers') + + @admin.register(models.Project) class ProjectAdmin(admin.ModelAdmin): """Project admin view.""" @@ -43,7 +51,8 @@ class ProjectAdmin(admin.ModelAdmin): list_filter = ['customer'] search_fields = ['name', 'customer__name'] - inlines = [TaskInline] + inlines = [TaskInline, ReviewerInline] + exclude = ('reviewers', ) @admin.register(models.TaskTemplate) diff --git a/timed/projects/migrations/0002_project_reviewers.py b/timed/projects/migrations/0002_project_reviewers.py new file mode 100644 index 000000000..69c377063 --- /dev/null +++ b/timed/projects/migrations/0002_project_reviewers.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-16 07:16 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='reviewers', + field=models.ManyToManyField(related_name='reviews', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 2220bc5b4..8527c4af9 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -1,5 +1,6 @@ """Models for the projects app.""" +from django.conf import settings from django.db import models @@ -48,6 +49,8 @@ class Project(models.Model): estimated_hours = models.PositiveIntegerField(blank=True, null=True) customer = models.ForeignKey('projects.Customer', related_name='projects') + reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, + related_name='reviews') def __str__(self): """Represent the model as a string. diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index c60fd90d6..1327b37e9 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -96,6 +96,7 @@ class ReportFilterSet(FilterSet): project = NumberFilter(name='task__project') customer = NumberFilter(name='task__project__customer') not_verified = BooleanFilter(name='verified_by', lookup_expr='isnull') + reviewer = NumberFilter(name='task__project__reviewers') class Meta: """Meta information for the report filter set.""" @@ -111,6 +112,7 @@ class Meta: 'not_verified', 'not_billable', 'review', + 'reviewer', ) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 9796fdb29..5ba878772 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -49,7 +49,9 @@ def test_report_list(self): 'task': self.reports[0].task.id, 'project': self.reports[0].task.project.id, 'customer': self.reports[0].task.project.customer.id, - 'include': 'user,task,task.project,task.project.customer' + 'include': ( + 'user,task,task.project,task.project.customer,verified_by' + ) }) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED @@ -60,6 +62,18 @@ def test_report_list(self): assert len(result['data']) == 1 assert result['data'][0]['id'] == str(self.reports[0].id) + def test_report_list_filter_reviewer(self): + report = self.reports[0] + report.task.project.reviewers.add(self.user) + + url = reverse('report-list') + + res = self.client.get(url, data={'reviewer': self.user.id}) + assert res.status_code == HTTP_200_OK + result = self.result(res) + assert len(result['data']) == 1 + assert result['data'][0]['id'] == str(report.id) + def test_report_list_verify(self): url_list = reverse('report-list') res = self.client.get(url_list, data={'not_verified': True}) From 32bcfac7cea7032ab1a552c124618605784d7040 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 10:24:42 +0200 Subject: [PATCH 146/980] Add billing type model and end point --- timed/conftest.py | 20 +++++++++++++ timed/projects/admin.py | 6 ++++ timed/projects/factories.py | 7 +++++ timed/projects/filters.py | 2 +- .../migrations/0003_auto_20170816_1420.py | 28 +++++++++++++++++++ timed/projects/models.py | 17 +++++++---- timed/projects/serializers.py | 13 ++++++++- timed/projects/tests/test_billing_type.py | 15 ++++++++++ timed/projects/urls.py | 1 + timed/projects/views.py | 8 ++++++ timed/tracking/filters.py | 2 ++ 11 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 timed/conftest.py create mode 100644 timed/projects/migrations/0003_auto_20170816_1420.py create mode 100644 timed/projects/tests/test_billing_type.py diff --git a/timed/conftest.py b/timed/conftest.py new file mode 100644 index 000000000..2ab71ed45 --- /dev/null +++ b/timed/conftest.py @@ -0,0 +1,20 @@ +import pytest +from django.contrib.auth import get_user_model + +from timed.jsonapi_test_case import JSONAPIClient + + +@pytest.fixture +def auth_client(db): + """Return instance of a JSONAPIClient that is logged in as test user.""" + user = get_user_model().objects.create_user( + username='user', + password='123qweasd', + first_name='Test', + last_name='User', + ) + + client = JSONAPIClient() + client.user = user + client.login('user', '123qweasd') + return client diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 52fa302d9..155ae4426 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -15,6 +15,12 @@ class CustomerAdmin(admin.ModelAdmin): search_fields = ['name'] +@admin.register(models.BillingType) +class BillingType(admin.ModelAdmin): + list_display = ['name'] + search_fields = ['name'] + + class TaskInlineFormset(BaseInlineFormSet): """Task formset defaulting to task templates when project is created.""" diff --git a/timed/projects/factories.py b/timed/projects/factories.py index df713c439..e94e702a0 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -21,6 +21,13 @@ class Meta: model = models.Customer +class BillingTypeFactory(DjangoModelFactory): + name = Faker('uuid4') + + class Meta: + model = models.BillingType + + class ProjectFactory(DjangoModelFactory): """Project factory.""" diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 63bdf1713..5caffe960 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -24,7 +24,7 @@ class Meta: """Meta information for the project filter set.""" model = models.Project - fields = ['archived', 'customer'] + fields = ['archived', 'customer', 'billing_type'] class MyMostFrequentTaskFilter(Filter): diff --git a/timed/projects/migrations/0003_auto_20170816_1420.py b/timed/projects/migrations/0003_auto_20170816_1420.py new file mode 100644 index 000000000..a52e8e1fa --- /dev/null +++ b/timed/projects/migrations/0003_auto_20170816_1420.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-16 12:20 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_project_reviewers'), + ] + + operations = [ + migrations.CreateModel( + name='BillingType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ], + ), + migrations.AddField( + model_name='project', + name='billing_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.BillingType'), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 8527c4af9..b587a56cf 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -31,6 +31,15 @@ class Meta: indexes = [models.Index(fields=['name', 'archived'])] +class BillingType(models.Model): + """Billing type defining how a project, resp. reports are being billed.""" + + name = models.CharField(max_length=255, unique=True) + + def __str__(self): + return self.name + + class Project(models.Model): """Project model. @@ -38,17 +47,15 @@ class Project(models.Model): belongs to a customer. """ - TYPES = ( - ('GH', 'Github'), - ('RM', 'Redmine') - ) - name = models.CharField(max_length=255) comment = models.TextField(blank=True) archived = models.BooleanField(default=False) estimated_hours = models.PositiveIntegerField(blank=True, null=True) customer = models.ForeignKey('projects.Customer', related_name='projects') + billing_type = models.ForeignKey(BillingType, + blank=True, null=True, + related_name='projects') reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='reviews') diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index ef19f4f17..581856dad 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -22,13 +22,23 @@ class Meta: ] +class BillingTypeSerializer(ModelSerializer): + class Meta: + model = models.BillingType + fields = ['name'] + + class ProjectSerializer(ModelSerializer): """Project serializer.""" customer = ResourceRelatedField(queryset=models.Customer.objects.all()) + billing_type = ResourceRelatedField( + queryset=models.BillingType.objects.all() + ) included_serializers = { - 'customer': 'timed.projects.serializers.CustomerSerializer' + 'customer': 'timed.projects.serializers.CustomerSerializer', + 'billing_type': 'timed.projects.serializers.BillingTypeSerializer' } class Meta: @@ -41,6 +51,7 @@ class Meta: 'estimated_hours', 'archived', 'customer', + 'billing_type' ] diff --git a/timed/projects/tests/test_billing_type.py b/timed/projects/tests/test_billing_type.py new file mode 100644 index 000000000..e3d597561 --- /dev/null +++ b/timed/projects/tests/test_billing_type.py @@ -0,0 +1,15 @@ +from django.core.urlresolvers import reverse +from rest_framework.status import HTTP_200_OK + +from timed.projects.factories import BillingTypeFactory + + +def test_billing_type_list(auth_client): + billing_type = BillingTypeFactory.create() + url = reverse('billing-type-list') + + res = auth_client.get(url) + assert res.status_code == HTTP_200_OK + json = res.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(billing_type.id) diff --git a/timed/projects/urls.py b/timed/projects/urls.py index ae74d0a70..c57c1b656 100644 --- a/timed/projects/urls.py +++ b/timed/projects/urls.py @@ -10,5 +10,6 @@ r.register(r'projects', views.ProjectViewSet, 'project') r.register(r'customers', views.CustomerViewSet, 'customer') r.register(r'tasks', views.TaskViewSet, 'task') +r.register(r'billing-types', views.BillingTypeViewSet, 'billing-type') urlpatterns = r.urls diff --git a/timed/projects/views.py b/timed/projects/views.py index 0de82a925..313ef0b2c 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -23,6 +23,14 @@ def get_queryset(self): ) +class BillingTypeViewSet(ReadOnlyModelViewSet): + serializer_class = serializers.BillingTypeSerializer + ordering = 'name' + + def get_queryset(self): + return models.BillingType.objects.all() + + class ProjectViewSet(ReadOnlyModelViewSet): """Project view set.""" diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 1327b37e9..a8fb9fbce 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -97,6 +97,7 @@ class ReportFilterSet(FilterSet): customer = NumberFilter(name='task__project__customer') not_verified = BooleanFilter(name='verified_by', lookup_expr='isnull') reviewer = NumberFilter(name='task__project__reviewers') + billing_type = NumberFilter(name='task__project__billing_type') class Meta: """Meta information for the report filter set.""" @@ -113,6 +114,7 @@ class Meta: 'not_billable', 'review', 'reviewer', + 'billing_type' ) From b86cc2ffc779276d7f88dcfbaba10771c51d85cf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 14:36:35 +0200 Subject: [PATCH 147/980] Task may not be null on report --- .../migrations/0003_auto_20170816_1436.py | 21 +++++++++++++++++++ timed/tracking/models.py | 5 +---- 2 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 timed/tracking/migrations/0003_auto_20170816_1436.py diff --git a/timed/tracking/migrations/0003_auto_20170816_1436.py b/timed/tracking/migrations/0003_auto_20170816_1436.py new file mode 100644 index 000000000..7677b7b71 --- /dev/null +++ b/timed/tracking/migrations/0003_auto_20170816_1436.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-16 12:36 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0002_report_verified_by'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='projects.Task'), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index d3ce9ccaf..16156c649 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -108,10 +108,7 @@ class Report(models.Model): duration = models.DurationField() review = models.BooleanField(default=False) not_billable = models.BooleanField(default=False) - task = models.ForeignKey('projects.Task', - null=True, - blank=True, - related_name='reports') + task = models.ForeignKey('projects.Task', related_name='reports') activity = models.ForeignKey(Activity, null=True, blank=True, From baca5164bb751dbd7ddbc27d64a884670d258282 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 16:17:35 +0200 Subject: [PATCH 148/980] Add missing user filter set --- timed/employment/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timed/employment/views.py b/timed/employment/views.py index 0c57c8112..2dec270a9 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -10,6 +10,7 @@ class UserViewSet(ReadOnlyModelViewSet): """User view set.""" serializer_class = serializers.UserSerializer + filter_class = filters.UserFilterSet def get_queryset(self): """Filter the queryset by the user of the request. From 430696b06b98361d52179c4dbb63baab31b6e009 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 16:07:17 +0200 Subject: [PATCH 149/980] Allow setting of verified_by on report but only for current staff user --- timed/tracking/serializers.py | 33 ++++++++++-- timed/tracking/tests/test_report.py | 82 +++++++++++++++++++++++++---- timed/tracking/views.py | 19 ++----- 3 files changed, 102 insertions(+), 32 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 70301cb91..7b29c9e86 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -1,5 +1,7 @@ """Serializers for the tracking app.""" +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, DurationField, @@ -115,7 +117,8 @@ class ReportSerializer(ModelSerializer): required=False) user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) - verified_by = ResourceRelatedField(read_only=True) + verified_by = ResourceRelatedField(queryset=get_user_model().objects, + required=False, allow_null=True) included_serializers = { 'task': 'timed.projects.serializers.TaskSerializer', @@ -129,17 +132,35 @@ def validate_date(self, value): user = self.context['request'].user owner = self.instance.user if self.instance.date != value and user != owner: - raise ValidationError('Only owner may change date') + raise ValidationError(_('Only owner may change date')) return value + def validate_verified_by(self, value): + user = self.context['request'].user + current_verified_by = self.instance and self.instance.verified_by + + if value == current_verified_by: + # no validation needed when nothing has changed + return value + + if value is None and user.is_staff: + # staff is allowed to reset verified by + return value + + if value is not None and user.is_staff and value == user: + # staff user is allowed to set it's own user as verified by + return value + + raise ValidationError(_('Only staff user may verify reports.')) + def validate_duration(self, value): """Only owner is allowed to change duration.""" if self.instance is not None: user = self.context['request'].user owner = self.instance.user if self.instance.duration != value and user != owner: - raise ValidationError('Only owner may change duration') + raise ValidationError(_('Only owner may change duration')) return value @@ -190,12 +211,14 @@ def validate(self, data): date=data.get('date') ).exists(): raise ValidationError( - 'You can\'t create an absence on a public holiday' + _('You can\'t create an absence on a public holiday') ) workdays = [int(day) for day in location.workdays] if data.get('date').isoweekday() not in workdays: - raise ValidationError('You can\'t create an absence on a weekend') + raise ValidationError( + _('You can\'t create an absence on a weekend') + ) return data diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 5ba878772..5c0a1ddcf 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -103,16 +103,6 @@ def test_report_list_verify_page(self): result = self.result(res) assert len(result['data']) == 5 - def test_report_detail_verify(self): - report = self.reports[0] - url = reverse('report-verify', args=[report.id]) - res = self.client.post(url) - - assert res.status_code == HTTP_200_OK - reports = Report.objects.filter(verified_by=self.user) - assert reports.count() == 1 - assert reports.first().id == report.id - def test_report_export_missing_type(self): """Should respond with a list of filtered reports.""" url = reverse('report-export') @@ -157,6 +147,9 @@ def test_report_create(self): 'id': task.id } }, + 'verified-by': { + 'data': None + }, } } } @@ -184,7 +177,6 @@ def test_report_create(self): def test_report_update_owner(self): """Should update an existing report.""" report = self.reports[0] - task = TaskFactory.create() data = { @@ -303,8 +295,40 @@ def test_report_update_not_staff_user(self): res = client.patch(url, data) assert res.status_code == HTTP_403_FORBIDDEN + def test_report_set_verified_by_not_staff_user(self): + """Not staff user may not set verified by.""" + self.user.is_staff = False + self.user.save() + + report = self.reports[0] + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + 'relationships': { + 'verified-by': { + 'data': { + 'id': self.user.id, + 'type': 'users' + } + }, + } + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + res = self.client.patch(url, data) + assert res.status_code == HTTP_400_BAD_REQUEST + def test_report_update_staff_user(self): report = self.reports[0] + data = { 'data': { 'type': 'reports', @@ -312,6 +336,42 @@ def test_report_update_staff_user(self): 'attributes': { 'comment': 'foobar', }, + 'relationships': { + 'verified-by': { + 'data': { + 'id': self.user.id, + 'type': 'users' + } + }, + } + } + } + + url = reverse('report-detail', args=[ + report.id + ]) + + res = self.client.patch(url, data) + assert res.status_code == HTTP_200_OK + + def test_report_reset_verified_by_staff_user(self): + """Staff user may reset verified by on report.""" + report = self.reports[0] + report.verified_by = self.user + report.save() + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + 'relationships': { + 'verified-by': { + 'data': None + }, + } } } diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 25473b781..b82ad5399 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -2,7 +2,7 @@ import django_excel from django.http import HttpResponseBadRequest -from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import list_route from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -125,11 +125,12 @@ def export(self, request): @list_route(methods=['post'], url_path='verify') def verify_list(self, request): """ - Verify all reports by given filter. + Bulk verify all reports by given filter. Authenticated user will be set as verified_by on given reports. """ + # TODO: only staff is allowed to do this queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: @@ -140,20 +141,6 @@ def verify_list(self, request): return Response(data={}) - @detail_route(methods=['post'], url_path='verify') - def verify_detail(self, request, pk=None): - """ - Verify given report. - - Authenticated user will be set as verified_by on given - report. - """ - report = self.get_object() - report.verified_by = request.user - report.save() - - return Response(data={}) - def get_queryset(self): """Select related to reduce queries. From ce7afad698fadc127cd02b61f074f4af44a55efd Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Aug 2017 16:16:19 +0200 Subject: [PATCH 150/980] Non staff user may not verify reports --- timed/tracking/tests/test_report.py | 10 ++++++++++ timed/tracking/views.py | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 5c0a1ddcf..0f1014cbf 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -91,6 +91,16 @@ def test_report_list_verify(self): result = self.result(res) assert len(result['data']) == 10 + def test_report_list_verify_non_admin(self): + """Non admin resp. non staff user may not verify reports.""" + self.user.is_staff = False + self.user.save() + + url_verify = reverse('report-verify') + res = self.client.post(url_verify, QUERY_STRING='user=%s' % + self.user.id) + assert res.status_code == HTTP_403_FORBIDDEN + def test_report_list_verify_page(self): url_verify = reverse('report-verify') res = self.client.post(url_verify, QUERY_STRING='user=%s&page_size=5' % diff --git a/timed/tracking/views.py b/timed/tracking/views.py index b82ad5399..e95756c17 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -3,7 +3,7 @@ import django_excel from django.http import HttpResponseBadRequest from rest_framework.decorators import list_route -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -122,7 +122,8 @@ def export(self, request): sheet, file_type=file_type, file_name='report.%s' % file_type ) - @list_route(methods=['post'], url_path='verify') + @list_route(methods=['post'], url_path='verify', + permission_classes=[IsAuthenticated, IsAdminUser]) def verify_list(self, request): """ Bulk verify all reports by given filter. @@ -130,7 +131,6 @@ def verify_list(self, request): Authenticated user will be set as verified_by on given reports. """ - # TODO: only staff is allowed to do this queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: From c6d6465d31227e78425e60846dc4eda6ba8cb388 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 17 Aug 2017 09:17:07 +0200 Subject: [PATCH 151/980] Use NumberFilter instead of BooleanFilter we can filter by 0 and 1 --- timed/tracking/filters.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index a8fb9fbce..2d620b03d 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -91,12 +91,14 @@ class Meta: class ReportFilterSet(FilterSet): """Filter set for the reports endpoint.""" - from_date = DateFilter(name='date', lookup_expr='gte') - to_date = DateFilter(name='date', lookup_expr='lte') - project = NumberFilter(name='task__project') - customer = NumberFilter(name='task__project__customer') - not_verified = BooleanFilter(name='verified_by', lookup_expr='isnull') - reviewer = NumberFilter(name='task__project__reviewers') + from_date = DateFilter(name='date', lookup_expr='gte') + to_date = DateFilter(name='date', lookup_expr='lte') + project = NumberFilter(name='task__project') + customer = NumberFilter(name='task__project__customer') + review = NumberFilter(name='review') + not_billable = NumberFilter(name='not_billable') + not_verified = NumberFilter(name='verified_by', lookup_expr='isnull') + reviewer = NumberFilter(name='task__project__reviewers') billing_type = NumberFilter(name='task__project__billing_type') class Meta: From 432334b3c33b56c90a93909ba2ba114eafc0737c Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 17 Aug 2017 11:15:50 +0200 Subject: [PATCH 152/980] Fixed tests --- timed/tracking/filters.py | 3 +-- timed/tracking/tests/test_report.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 2d620b03d..f6e9ec68d 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -2,8 +2,7 @@ from functools import wraps -from django_filters import (BooleanFilter, DateFilter, Filter, FilterSet, - NumberFilter) +from django_filters import DateFilter, Filter, FilterSet, NumberFilter from timed.tracking import models diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 0f1014cbf..3f40900b4 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -76,7 +76,7 @@ def test_report_list_filter_reviewer(self): def test_report_list_verify(self): url_list = reverse('report-list') - res = self.client.get(url_list, data={'not_verified': True}) + res = self.client.get(url_list, data={'not_verified': 1}) assert res.status_code == HTTP_200_OK result = self.result(res) assert len(result['data']) == 20 @@ -86,7 +86,7 @@ def test_report_list_verify(self): self.user.id) assert res.status_code == HTTP_200_OK - res = self.client.get(url_list, data={'not_verified': False}) + res = self.client.get(url_list, data={'not_verified': 0}) assert res.status_code == HTTP_200_OK result = self.result(res) assert len(result['data']) == 10 @@ -108,7 +108,7 @@ def test_report_list_verify_page(self): assert res.status_code == HTTP_200_OK url_list = reverse('report-list') - res = self.client.get(url_list, data={'not_verified': False}) + res = self.client.get(url_list, data={'not_verified': 0}) assert res.status_code == HTTP_200_OK result = self.result(res) assert len(result['data']) == 5 From 48b20f1aabd7e234d1497cf4bd4ddefabb95b057 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 17 Aug 2017 11:36:53 +0200 Subject: [PATCH 153/980] Split attendance from_datetime and to_datetime into date and time fields. This is needed as attendance time should be fixed and not change when being in a different time zone. --- timed/employment/migrations/0001_initial.py | 4 +- timed/projects/migrations/0001_initial.py | 13 ++++- .../migrations/0002_project_reviewers.py | 22 -------- .../migrations/0003_auto_20170816_1420.py | 28 ---------- timed/tracking/factories.py | 55 +------------------ timed/tracking/migrations/0001_initial.py | 12 ++-- .../migrations/0002_report_verified_by.py | 23 -------- .../migrations/0003_auto_20170816_1436.py | 21 ------- timed/tracking/models.py | 14 +++-- timed/tracking/serializers.py | 5 +- timed/tracking/tests/test_attendance.py | 18 ++---- 11 files changed, 42 insertions(+), 173 deletions(-) delete mode 100644 timed/projects/migrations/0002_project_reviewers.py delete mode 100644 timed/projects/migrations/0003_auto_20170816_1420.py delete mode 100644 timed/tracking/migrations/0002_report_verified_by.py delete mode 100644 timed/tracking/migrations/0003_auto_20170816_1436.py diff --git a/timed/employment/migrations/0001_initial.py b/timed/employment/migrations/0001_initial.py index 71930004c..ea2e1c261 100644 --- a/timed/employment/migrations/0001_initial.py +++ b/timed/employment/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-15 11:09 +# Generated by Django 1.11.4 on 2017-08-17 09:16 from __future__ import unicode_literals from django.conf import settings @@ -41,8 +41,8 @@ class Migration(migrations.Migration): ], options={ 'abstract': False, - 'verbose_name': 'user', 'verbose_name_plural': 'users', + 'verbose_name': 'user', }, managers=[ ('objects', timed.employment.models.UserManager()), diff --git a/timed/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py index 0d97e7898..910c06335 100644 --- a/timed/projects/migrations/0001_initial.py +++ b/timed/projects/migrations/0001_initial.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-15 11:09 +# Generated by Django 1.11.4 on 2017-08-17 09:16 from __future__ import unicode_literals +from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -11,9 +12,17 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='BillingType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ], + ), migrations.CreateModel( name='Customer', fields=[ @@ -33,7 +42,9 @@ class Migration(migrations.Migration): ('comment', models.TextField(blank=True)), ('archived', models.BooleanField(default=False)), ('estimated_hours', models.PositiveIntegerField(blank=True, null=True)), + ('billing_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.BillingType')), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.Customer')), + ('reviewers', models.ManyToManyField(related_name='reviews', to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( diff --git a/timed/projects/migrations/0002_project_reviewers.py b/timed/projects/migrations/0002_project_reviewers.py deleted file mode 100644 index 69c377063..000000000 --- a/timed/projects/migrations/0002_project_reviewers.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-16 07:16 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('projects', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='reviewers', - field=models.ManyToManyField(related_name='reviews', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/timed/projects/migrations/0003_auto_20170816_1420.py b/timed/projects/migrations/0003_auto_20170816_1420.py deleted file mode 100644 index a52e8e1fa..000000000 --- a/timed/projects/migrations/0003_auto_20170816_1420.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-16 12:20 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0002_project_reviewers'), - ] - - operations = [ - migrations.CreateModel( - name='BillingType', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ], - ), - migrations.AddField( - model_name='project', - name='billing_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.BillingType'), - ), - ] diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index cfbb9b7a0..f16952def 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -16,67 +16,18 @@ faker = FakerFactory.create() -def begin_of_day(day): - """Determine the start of a day. - - :param datetime.datetime day: The datetime to get the day from - :return: The start of the day - :rtype: datetime.datetime - """ - return datetime.datetime( - day.year, - day.month, - day.day, - 0, 0, 0, - tzinfo=tzinfo - ) - - -def end_of_day(day): - """Determine the end of a day. - - :param datetime.datetime day: The datetime to get the day from - :return: The end of the day - :rtype: datetime.datetime - """ - return begin_of_day(day) + datetime.timedelta(days=1) - - class AttendanceFactory(DjangoModelFactory): """Attendance factory.""" - date = datetime.date.today() + date = Faker('date') + from_time = Faker('time') + to_time = Faker('time') user = SubFactory('timed.employment.factories.UserFactory') - @lazy_attribute - def from_datetime(self): - """Generate a datetime between the start and the end of the day. - - :return: The generated datetime - :rtype: datetime.datetime - """ - return faker.date_time_between_dates( - datetime_start=begin_of_day(self.date), - datetime_end=end_of_day(self.date), - tzinfo=tzinfo - ) - - @lazy_attribute - def to_datetime(self): - """Generate a datetime based on from_datetime. - - :return: The generated datetime - :rtype: datetime.datetime - """ - hours = randint(1, 5) - - return self.from_datetime + datetime.timedelta(hours=hours) - class Meta: """Meta informations for the attendance factory.""" model = models.Attendance - exclude = ('date',) class ReportFactory(DjangoModelFactory): diff --git a/timed/tracking/migrations/0001_initial.py b/timed/tracking/migrations/0001_initial.py index c6db2f4ff..1ca35bf1a 100644 --- a/timed/tracking/migrations/0001_initial.py +++ b/timed/tracking/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-15 11:09 +# Generated by Django 1.11.4 on 2017-08-17 09:16 from __future__ import unicode_literals import datetime @@ -14,8 +14,8 @@ class Migration(migrations.Migration): dependencies = [ ('employment', '0001_initial'), - ('projects', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0001_initial'), ] operations = [ @@ -56,8 +56,9 @@ class Migration(migrations.Migration): name='Attendance', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('from_datetime', models.DateTimeField()), - ('to_datetime', models.DateTimeField()), + ('date', models.DateField()), + ('from_time', models.TimeField()), + ('to_time', models.TimeField()), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to=settings.AUTH_USER_MODEL)), ], ), @@ -73,8 +74,9 @@ class Migration(migrations.Migration): ('added', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ('activity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='tracking.Activity')), - ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='projects.Task')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='projects.Task')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL)), + ('verified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ], ), migrations.AddIndex( diff --git a/timed/tracking/migrations/0002_report_verified_by.py b/timed/tracking/migrations/0002_report_verified_by.py deleted file mode 100644 index 292176ffb..000000000 --- a/timed/tracking/migrations/0002_report_verified_by.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-15 14:09 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('tracking', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='report', - name='verified_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/timed/tracking/migrations/0003_auto_20170816_1436.py b/timed/tracking/migrations/0003_auto_20170816_1436.py deleted file mode 100644 index 7677b7b71..000000000 --- a/timed/tracking/migrations/0003_auto_20170816_1436.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-16 12:36 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('tracking', '0002_report_verified_by'), - ] - - operations = [ - migrations.AlterField( - model_name='report', - name='task', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='projects.Task'), - ), - ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 16156c649..bbbbe2cf5 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -75,10 +75,13 @@ class Attendance(models.Model): """Attendance model. An attendance is a timespan in which a user was present at work. + Timespan should not be time zone aware hence splitting into date and + from resp. to time fields. """ - from_datetime = models.DateTimeField() - to_datetime = models.DateTimeField() + date = models.DateField() + from_time = models.TimeField() + to_time = models.TimeField() user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='attendances') @@ -88,10 +91,11 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{0}: {1} - {2}'.format( + return '{0}: {1} {2} - {3}'.format( self.user, - self.from_datetime.strftime('%d.%m.%Y %h:%i'), - self.to_datetime.strftime('%d.%m.%Y %h:%i') + self.date.strftime('%Y-%m-%d'), + self.from_time.strftime('%H:%M'), + self.to_time.strftime('%H:%M') ) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 7b29c9e86..b381a96a2 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -102,8 +102,9 @@ class Meta: model = models.Attendance fields = [ - 'from_datetime', - 'to_datetime', + 'date', + 'from_time', + 'to_time', 'user', ] diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index db963b8f2..2a807a70a 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -1,7 +1,5 @@ """Tests for the attendances endpoint.""" -from datetime import timedelta - from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, @@ -63,15 +61,14 @@ def test_attendance_detail(self): def test_attendance_create(self): """Should create a new attendance and automatically set the user.""" - attendance = AttendanceFactory.build() - data = { 'data': { 'type': 'attendances', 'id': None, 'attributes': { - 'from-datetime': attendance.from_datetime.isoformat(), - 'to-datetime': attendance.from_datetime.isoformat() + 'date': '2017-01-01', + 'from-time': '08:00', + 'to-time': '10:00' } } } @@ -97,15 +94,12 @@ def test_attendance_update(self): """Should update and existing attendance.""" attendance = self.attendances[0] - attendance.to_datetime += timedelta(hours=1) - data = { 'data': { 'type': 'attendances', 'id': attendance.id, 'attributes': { - 'from-datetime': attendance.from_datetime.isoformat(), - 'to-datetime': attendance.to_datetime.isoformat() + 'to-time': '15:00:00' } } } @@ -123,8 +117,8 @@ def test_attendance_update(self): result = self.result(res) assert ( - result['data']['attributes']['to-datetime'] == - data['data']['attributes']['to-datetime'] + result['data']['attributes']['to-time'] == + data['data']['attributes']['to-time'] ) def test_attendance_delete(self): From c9525bddde58f1a85e60e091392c7267ddbbed70 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 17 Aug 2017 14:49:45 +0200 Subject: [PATCH 154/980] Disallow changing of verified reports Only admin users may change verified reports as owner doesn't know whether item has already been billed. --- setup.py | 1 + timed/tracking/permissions.py | 50 ++++++++++++++++++++++++----- timed/tracking/tests/test_report.py | 29 +++++++++++++++-- timed/tracking/views.py | 14 ++++++-- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/setup.py b/setup.py index 8ea566884..03dc00165 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ 'pyexcel-ods3==0.4.0', 'pyexcel-xlsx==0.4.1', 'django-environ==0.4.3', + 'rest_condition==1.0.3', ), keywords='timetracking', url='https://adfinis-sygroup.ch/', diff --git a/timed/tracking/permissions.py b/timed/tracking/permissions.py index 62a9e22a6..96ae05257 100644 --- a/timed/tracking/permissions.py +++ b/timed/tracking/permissions.py @@ -1,16 +1,50 @@ -from rest_framework.permissions import SAFE_METHODS, BasePermission +from rest_framework.permissions import (SAFE_METHODS, BasePermission, + IsAdminUser, IsAuthenticated) -class IsOwnerOrStaffElseReadOnly(BasePermission): +class IsOwner(BasePermission): + """Allows access to object only to owners.""" + + def has_object_permission(self, request, view, obj): + return obj.user_id == request.user.id + + +class IsUnverified(BasePermission): + """Allows access only to verified objects.""" + + def has_object_permission(self, request, view, obj): + return obj.verified_by_id is None + + +class IsReadOnly(BasePermission): + """Allows read only methods.""" + + def has_permission(self, request, view): + return request.method in SAFE_METHODS + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + +class IsAuthenticated(IsAuthenticated): """ - Restrict writing to object for owner or staff only. + Support mixing permission IsAuthenticated with object permission. - Changing an object is only allowed if object belongs to current user - or user is a staff member. + This is needed to use IsAdminUser with rest condition and or + operator. """ def has_object_permission(self, request, view, obj): - if request.method in SAFE_METHODS: - return True + return self.has_permission(request, view) - return obj.user_id == request.user.id or request.user.is_staff + +class IsAdminUser(IsAdminUser): + """ + Support mixing permission IsAdminUser with object permission. + + This is needed to use IsAdminUser with rest condition and or + operator. + """ + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 3f40900b4..f69656437 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -184,6 +184,32 @@ def test_report_create(self): int(data['data']['relationships']['task']['data']['id']) ) + def test_report_update_verified_as_non_staff_but_owner(self): + """Test that an owner (not staff) may not change a verified report.""" + report = self.reports[0] + report.verified_by = self.user + report.duration = timedelta(hours=2) + report.save() + + url = reverse('report-detail', args=[ + report.id + ]) + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'duration': '01:00:00', + }, + } + } + + client = JSONAPIClient() + client.login('test', '123qweasd') + res = client.patch(url, data) + assert res.status_code == HTTP_403_FORBIDDEN + def test_report_update_owner(self): """Should update an existing report.""" report = self.reports[0] @@ -315,9 +341,6 @@ def test_report_set_verified_by_not_staff_user(self): 'data': { 'type': 'reports', 'id': report.id, - 'attributes': { - 'comment': 'foobar', - }, 'relationships': { 'verified-by': { 'data': { diff --git a/timed/tracking/views.py b/timed/tracking/views.py index e95756c17..e8901cb86 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -2,13 +2,14 @@ import django_excel from django.http import HttpResponseBadRequest +from rest_condition import C from rest_framework.decorators import list_route -from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from timed.tracking import filters, models, serializers -from timed.tracking.permissions import IsOwnerOrStaffElseReadOnly +from timed.tracking.permissions import (IsAdminUser, IsAuthenticated, IsOwner, + IsReadOnly, IsUnverified) class ActivityViewSet(ModelViewSet): @@ -78,7 +79,14 @@ class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet - permission_classes = [IsAuthenticated, IsOwnerOrStaffElseReadOnly] + permission_classes = [ + # admin user can change all + C(IsAuthenticated) & C(IsAdminUser) | + # owner may only change its own unverified reports + C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) | + # all authenticated users may read all reports + C(IsAuthenticated) & C(IsReadOnly) + ] ordering = ('id', ) ordering_fields = ( 'date', From db1e1d0b77971b528ece28ee515febd7a2eb67d9 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 18 Aug 2017 10:04:51 +0200 Subject: [PATCH 155/980] Use number filter for booleans --- timed/projects/filters.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 5caffe960..5ef91b35b 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -2,7 +2,7 @@ from datetime import date, timedelta from django.db.models import Count -from django_filters import Filter, FilterSet +from django_filters import Filter, FilterSet, NumberFilter from timed.projects import models @@ -10,6 +10,8 @@ class CustomerFilterSet(FilterSet): """Filter set for the customers endpoint.""" + archived = NumberFilter(name='archived') + class Meta: """Meta information for the customer filter set.""" @@ -20,6 +22,8 @@ class Meta: class ProjectFilterSet(FilterSet): """Filter set for the projects endpoint.""" + archived = NumberFilter(name='archived') + class Meta: """Meta information for the project filter set.""" @@ -63,6 +67,7 @@ class TaskFilterSet(FilterSet): """Filter set for the tasks endpoint.""" my_most_frequent = MyMostFrequentTaskFilter() + archived = NumberFilter(name='archived') class Meta: """Meta information for the task filter set.""" From 595a58b035eeb08647b2e26870245db987838405 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 18 Aug 2017 10:15:42 +0200 Subject: [PATCH 156/980] Fixed tests --- timed/projects/tests/test_customer.py | 2 +- timed/projects/tests/test_project.py | 2 +- timed/projects/tests/test_task.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 142661a65..36acd0018 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -30,7 +30,7 @@ def test_customer_list(self): url = reverse('customer-list') noauth_res = self.noauth_client.get(url) - res = self.client.get(url, data={'archived': False}) + res = self.client.get(url, data={'archived': 0}) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_200_OK diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 322e4bed9..aa08ffe17 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -30,7 +30,7 @@ def test_project_list(self): url = reverse('project-list') noauth_res = self.noauth_client.get(url) - res = self.client.get(url, data={'archived': False}) + res = self.client.get(url, data={'archived': 0}) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_200_OK diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index c442492ae..16fda7b79 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -29,7 +29,7 @@ def test_task_list(self): url = reverse('task-list') noauth_res = self.noauth_client.get(url) - res = self.client.get(url, data={'archived': False}) + res = self.client.get(url, data={'archived': 0}) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_200_OK From 5c9bdf25be822dec1980adc2e3b1f0ae452712b7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Aug 2017 11:17:08 +0200 Subject: [PATCH 157/980] Add management command to notify reviewers on unverified reports --- timed/employment/models.py | 5 + .../commands/notify_reviewers_unverified.py | 107 ++++++++++++++++++ .../mail/notify_reviewers_unverified.txt | 3 + .../tests/test_notify_reviewers_unverified.py | 40 +++++++ timed/settings.py | 2 + 5 files changed, 157 insertions(+) create mode 100644 timed/reports/management/commands/notify_reviewers_unverified.py create mode 100644 timed/reports/templates/mail/notify_reviewers_unverified.txt create mode 100644 timed/reports/tests/test_notify_reviewers_unverified.py diff --git a/timed/employment/models.py b/timed/employment/models.py index 2ffa69fbd..79c4eacb4 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -58,6 +58,11 @@ def all_supervisors(self): supervisees_count=models.Count('supervisees')) return objects.filter(supervisees_count__gt=0) + def all_reviewers(self): + objects = self.model.objects.annotate( + reviews_count=models.Count('reviews')) + return objects.filter(reviews__gt=0) + def all_supervisees(self): objects = self.model.objects.annotate( supervisors_count=models.Count('supervisors')) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py new file mode 100644 index 000000000..20027c7eb --- /dev/null +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -0,0 +1,107 @@ +from datetime import date, timedelta + +from dateutil.relativedelta import relativedelta +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.mail import send_mass_mail +from django.core.management.base import BaseCommand +from django.db.models import Count +from django.template.loader import render_to_string + +from timed.tracking.models import Report + + +class Command(BaseCommand): + """ + Notify reviewers of projects with unverified reports. + + Notifications will be sent when reviewer has projects with reports + which are unverified in given time frame. + + Example how it works: + + We have set following options + + Today = Friday 4/8/2017 + Months = 1 + Offset = 5 + + with these set reports would be checked between 1/7/2017 and 31/7/2017. + A notification will be sent to reviewers if there are reports on + projects where they are added as reviewer. + """ + + help = 'Notify reviewers of projects with unverified reports.' + + def add_arguments(self, parser): + parser.add_argument( + '--months', + default=1, + type=int, + dest='months', + help='Number of months to check unverified reports in.' + ) + parser.add_argument( + '--offset', + default=5, + type=int, + dest='offset', + help='Period will end today minus given offset.' + ) + + def handle(self, *args, **options): + months = options['months'] + offset = options['offset'] + + today = date.today() + # -1 as we also skip today + end = today - timedelta(days=offset - 1) + # -1 days as first day of month is needed + start = end - relativedelta(months=months, days=-1) + + reports = self._get_unverified_reports(start, end) + self._notify_reviewers(start, end, reports) + + def _get_unverified_reports(self, start, end): + """ + Get unverified reports. + + Unverified reports are reports on project which have a reviewer + assigned but are not verified in given time frame. + """ + queryset = Report.objects.filter( + date__range=[start, end], + verified_by__isnull=True + ) + queryset = queryset.annotate( + num_reviewers=Count('task__project__reviewers') + ) + queryset = queryset.filter(num_reviewers__gt=0) + + return queryset + + def _notify_reviewers(self, start, end, reports): + """Notify reviewers on their unverified reports.""" + User = get_user_model() + reviewers = User.objects.all_reviewers().filter(email__isnull=False) + subject = '[Timed] Verification of reports' + from_email = settings.DEFAULT_FROM_EMAIL + mails = [] + + for reviewer in reviewers: + if reports.filter(task__project__reviewers=reviewer).exists(): + body = render_to_string( + 'mail/notify_reviewers_unverified.txt', { + # we need start and end date in system format + 'start': str(start), + 'end': str(end), + 'reviewer': reviewer, + 'protocol': settings.HOST_PROTOCOL, + 'domain': settings.HOST_DOMAIN, + } + ) + + mails.append((subject, body, from_email, [reviewer.email])) + + if len(mails) > 0: + send_mass_mail(mails) diff --git a/timed/reports/templates/mail/notify_reviewers_unverified.txt b/timed/reports/templates/mail/notify_reviewers_unverified.txt new file mode 100644 index 000000000..2ed1089fa --- /dev/null +++ b/timed/reports/templates/mail/notify_reviewers_unverified.txt @@ -0,0 +1,3 @@ +There are unverified reports which need your attention. + +Go to <{{protocol}}://{{domain}}/reschedule?from_date={{start}}&to_date={{end}}&reviewer={{reviewer.id}}> diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py new file mode 100644 index 000000000..2294f230e --- /dev/null +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -0,0 +1,40 @@ +from datetime import date + +import pytest +from django.core.management import call_command + +from timed.employment.factories import UserFactory +from timed.projects.factories import ProjectFactory, TaskFactory +from timed.tracking.factories import ReportFactory + + +@pytest.mark.freeze_time('2017-8-4') +def test_notify_reviewers(db, mailoutbox): + """Test time range 2017-7-1 till 2017-7-31.""" + # a reviewer which will be notified + reviewer_work = UserFactory.create() + project_work = ProjectFactory.create() + project_work.reviewers.add(reviewer_work) + task_work = TaskFactory.create(project=project_work) + ReportFactory.create(date=date(2017, 7, 1), task=task_work, + verified_by=None) + + # a reviewer which doesn't have any unverfied reports + reviewer_no_work = UserFactory.create() + project_no_work = ProjectFactory.create() + project_no_work.reviewers.add(reviewer_no_work) + task_no_work = TaskFactory.create(project=project_no_work) + ReportFactory.create(date=date(2017, 7, 1), task=task_no_work, + verified_by=reviewer_no_work) + + call_command('notify_reviewers_unverified') + + # checks + assert len(mailoutbox) == 1 + mail = mailoutbox[0] + assert mail.to == [reviewer_work.email] + url = ( + 'http://localhost:4200/reschedule?from_date=2017-07-01&' + 'to_date=2017-07-31&reviewer=%d' + ) % reviewer_work.id + assert url in mail.body diff --git a/timed/settings.py b/timed/settings.py index fe661b635..6314355c9 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -35,6 +35,8 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): DEBUG = env.bool('DJANGO_DEBUG', default=default(True, False)) SECRET_KEY = env.str('DJANGO_SECRET_KEY', default=default('uuuuuuuuuu')) ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=default(['*'])) +HOST_PROTOCOL = env.str('DJANGO_HOST_PROTOCOL', default=default('http')) +HOST_DOMAIN = env.str('DJANGO_HOST_DOMAIN', default=default('localhost:4200')) INSTALLED_APPS = [ From 311f3c5cf5da67a3139de95a543a9dee27d8b70d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Aug 2017 14:53:16 +0200 Subject: [PATCH 158/980] Expose user is_active flag --- timed/employment/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 232bf5479..555f65425 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -187,6 +187,7 @@ class Meta: 'employments', 'worktime_balance', 'is_staff', + 'is_active', 'user_absence_types', ] From 9e14e773f810488384f160e7f25b395b6c8fa075 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Aug 2017 14:58:36 +0200 Subject: [PATCH 159/980] Correct attendance filter to date --- timed/tracking/filters.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index f6e9ec68d..23f2f81a5 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -78,13 +78,11 @@ class Meta: class AttendanceFilterSet(FilterSet): """Filter set for the attendance endpoint.""" - day = DateFilter(name='from_datetime', lookup_expr='date') - class Meta: """Meta information for the attendance filter set.""" model = models.Attendance - fields = ['day'] + fields = ['date'] class ReportFilterSet(FilterSet): From c9fbe18dcda5230649014e6ad983e9a346664411 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Aug 2017 16:15:39 +0200 Subject: [PATCH 160/980] Change default ordering on ReportViewSet to date Makes api cleaner as ordering is predictable for humans whereas ids are not. --- timed/tracking/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index e8901cb86..71e841efb 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -87,7 +87,7 @@ class ReportViewSet(ModelViewSet): # all authenticated users may read all reports C(IsAuthenticated) & C(IsReadOnly) ] - ordering = ('id', ) + ordering = ('date', ) ordering_fields = ( 'date', 'duration', From 5dc68639033cf7d8e8f11224c7b35b579642fc0f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Aug 2017 16:32:45 +0200 Subject: [PATCH 161/980] Update to version 0.1.0 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index fc1abb65b..1487589d7 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -__version__ = '0.0.0' +__version__ = '0.1.0' From f2208330ae8c928b0e1ad888525c6a675dc34274 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 23 Aug 2017 10:30:26 +0200 Subject: [PATCH 162/980] Disable delete action site wide --- timed/employment/admin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index ac30cb7bc..cd9197d81 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -10,6 +10,10 @@ from timed.employment import models +# do not allow deletion of objects site wide +# objects need to be deactivated resp. archived +admin.site.disable_action('delete_selected') + class SupervisorInline(admin.TabularInline): model = models.User.supervisors.through From b85dc1c249b23190074d2007b8fe7a702b6623c4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 23 Aug 2017 10:30:53 +0200 Subject: [PATCH 163/980] Do not allow deletion of user --- timed/employment/admin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index cd9197d81..405113814 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -117,6 +117,10 @@ class UserAdmin(UserAdmin): ] exclude = ('supervisors', ) + def has_delete_permission(self, request, obj=None): + """Users may not be deleted to keep tracking history.""" + return False + @admin.register(models.Location) class LocationAdmin(admin.ModelAdmin): From aaa911ce9c35d9ce66e268e10df4d117ba47c03b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 23 Aug 2017 10:31:14 +0200 Subject: [PATCH 164/980] Additional action on user admin to deactivate and mark users --- timed/employment/admin.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 405113814..f438d9b0a 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -115,7 +115,35 @@ class UserAdmin(UserAdmin): SupervisorInline, SuperviseeInline, EmploymentInline, OvertimeCreditInline, AbsenceCreditInline ] - exclude = ('supervisors', ) + list_display = ('username', 'first_name', 'last_name', 'is_staff', + 'is_active') + + actions = [ + 'disable_users', + 'enable_users', + 'disable_staff_status', + 'enable_staff_status' + ] + + def disable_users(self, request, queryset): + queryset.update(is_active=False) + disable_users.short_description = _('Disable selected users') + + def enable_users(self, request, queryset): + queryset.update(is_active=True) + enable_users.short_description = _('Enable selected users') + + def disable_staff_status(self, request, queryset): + queryset.update(is_staff=False) + disable_staff_status.short_description = _( + 'Disable staff status of selected users' + ) + + def enable_staff_status(self, request, queryset): + queryset.update(is_staff=True) + enable_staff_status.short_description = _( + 'Enable staff status of selected users' + ) def has_delete_permission(self, request, obj=None): """Users may not be deleted to keep tracking history.""" From f3c3ea0ae3ac4f4680603647fc1a50dd14cea3d7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 23 Aug 2017 10:42:56 +0200 Subject: [PATCH 165/980] Properly configure all delete permissions on admin --- timed/employment/admin.py | 9 +++++++++ timed/projects/admin.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index f438d9b0a..d0c667707 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -96,6 +96,9 @@ class EmploymentInline(admin.TabularInline): model = models.Employment extra = 0 + def has_delete_permission(self, request, obj=None): + return False + class OvertimeCreditInline(admin.TabularInline): model = models.OvertimeCredit @@ -157,6 +160,9 @@ class LocationAdmin(admin.ModelAdmin): list_display = ['name'] search_fields = ['name'] + def has_delete_permission(self, request, obj=None): + return False + @admin.register(models.PublicHoliday) class PublicHolidayAdmin(admin.ModelAdmin): @@ -171,3 +177,6 @@ class AbsenceTypeAdmin(admin.ModelAdmin): """Absence type admin view.""" list_display = ['name'] + + def has_delete_permission(self, request, obj=None): + return False diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 155ae4426..601a17f02 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -14,6 +14,9 @@ class CustomerAdmin(admin.ModelAdmin): list_display = ['name'] search_fields = ['name'] + def has_delete_permission(self, request, obj=None): + return False + @admin.register(models.BillingType) class BillingType(admin.ModelAdmin): @@ -41,6 +44,9 @@ def get_extra(self, request, obj=None, **kwargs): return 0 return models.TaskTemplate.objects.count() + def has_delete_permission(self, request, obj=None): + return False + class ReviewerInline(admin.TabularInline): model = models.Project.reviewers.through @@ -60,6 +66,9 @@ class ProjectAdmin(admin.ModelAdmin): inlines = [TaskInline, ReviewerInline] exclude = ('reviewers', ) + def has_delete_permission(self, request, obj=None): + return False + @admin.register(models.TaskTemplate) class TaskTemplateAdmin(admin.ModelAdmin): From ec5e57db92e299d91cd935a3657c42311a239923 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 23 Aug 2017 10:46:18 +0200 Subject: [PATCH 166/980] Allow deleting of billing types --- .../migrations/0002_auto_20170823_1045.py | 21 +++++++++++++++++++ timed/projects/models.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 timed/projects/migrations/0002_auto_20170823_1045.py diff --git a/timed/projects/migrations/0002_auto_20170823_1045.py b/timed/projects/migrations/0002_auto_20170823_1045.py new file mode 100644 index 000000000..20b212a1e --- /dev/null +++ b/timed/projects/migrations/0002_auto_20170823_1045.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-23 08:45 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='billing_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='projects.BillingType'), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index b587a56cf..621f3afcf 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -53,7 +53,7 @@ class Project(models.Model): estimated_hours = models.PositiveIntegerField(blank=True, null=True) customer = models.ForeignKey('projects.Customer', related_name='projects') - billing_type = models.ForeignKey(BillingType, + billing_type = models.ForeignKey(BillingType, on_delete=models.SET_NULL, blank=True, null=True, related_name='projects') reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, From 15799dab78f85fee6739c60abbc4a7ebdc65e01e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 23 Aug 2017 11:09:51 +0200 Subject: [PATCH 167/980] Allow deletion if it doesn't affect reports --- timed/employment/admin.py | 14 ++++++------- .../migrations/0002_auto_20170823_1051.py | 21 +++++++++++++++++++ timed/employment/models.py | 2 +- timed/projects/admin.py | 6 +++--- 4 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 timed/employment/migrations/0002_auto_20170823_1051.py diff --git a/timed/employment/admin.py b/timed/employment/admin.py index d0c667707..a6140d17c 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -96,9 +96,6 @@ class EmploymentInline(admin.TabularInline): model = models.Employment extra = 0 - def has_delete_permission(self, request, obj=None): - return False - class OvertimeCreditInline(admin.TabularInline): model = models.OvertimeCredit @@ -149,8 +146,7 @@ def enable_staff_status(self, request, queryset): ) def has_delete_permission(self, request, obj=None): - """Users may not be deleted to keep tracking history.""" - return False + return obj and not obj.reports.exists() @admin.register(models.Location) @@ -161,7 +157,7 @@ class LocationAdmin(admin.ModelAdmin): search_fields = ['name'] def has_delete_permission(self, request, obj=None): - return False + return obj and not obj.employments.exists() @admin.register(models.PublicHoliday) @@ -179,4 +175,8 @@ class AbsenceTypeAdmin(admin.ModelAdmin): list_display = ['name'] def has_delete_permission(self, request, obj=None): - return False + return ( + obj and + not obj.absences.exists() and + not obj.absence_credits.exists() + ) diff --git a/timed/employment/migrations/0002_auto_20170823_1051.py b/timed/employment/migrations/0002_auto_20170823_1051.py new file mode 100644 index 000000000..63af80b30 --- /dev/null +++ b/timed/employment/migrations/0002_auto_20170823_1051.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-23 08:51 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='employment', + name='location', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employments', to='employment.Location'), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 79c4eacb4..55b62cf72 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -86,7 +86,7 @@ class Employment(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='employments') - location = models.ForeignKey(Location) + location = models.ForeignKey(Location, related_name='employments') percentage = models.IntegerField(validators=[ MinValueValidator(0), MaxValueValidator(100)]) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 601a17f02..9ac0ba571 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -15,7 +15,7 @@ class CustomerAdmin(admin.ModelAdmin): search_fields = ['name'] def has_delete_permission(self, request, obj=None): - return False + return obj and not obj.projects.exists() @admin.register(models.BillingType) @@ -45,7 +45,7 @@ def get_extra(self, request, obj=None, **kwargs): return models.TaskTemplate.objects.count() def has_delete_permission(self, request, obj=None): - return False + return obj and not obj.reports.exists() class ReviewerInline(admin.TabularInline): @@ -67,7 +67,7 @@ class ProjectAdmin(admin.ModelAdmin): exclude = ('reviewers', ) def has_delete_permission(self, request, obj=None): - return False + return obj and not obj.tasks.exists() @admin.register(models.TaskTemplate) From 796cc45534b317f3ac6067c804e822a4bdd4bce4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 23 Aug 2017 11:31:07 +0200 Subject: [PATCH 168/980] Task can not be deleted It is not clear how a delete option depends on task without overwriting template --- timed/projects/admin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 9ac0ba571..11d7f2780 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -45,7 +45,10 @@ def get_extra(self, request, obj=None, **kwargs): return models.TaskTemplate.objects.count() def has_delete_permission(self, request, obj=None): - return obj and not obj.reports.exists() + # for some reason obj is parent object and not task + # so this doesn't work + # return obj and not obj.reports.exists() + return False class ReviewerInline(admin.TabularInline): From fb992e1bcc95e64864366a0f5e87f129b729ee26 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 25 Aug 2017 09:44:20 +0200 Subject: [PATCH 169/980] Add tour_done flag and allow user to change it --- .../migrations/0003_user_tour_done.py | 20 ++++++++++ timed/employment/models.py | 6 +++ timed/employment/serializers.py | 18 +++++++++ timed/employment/tests/test_user.py | 37 ++++++++++++++++--- timed/employment/views.py | 18 +++++---- 5 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 timed/employment/migrations/0003_user_tour_done.py diff --git a/timed/employment/migrations/0003_user_tour_done.py b/timed/employment/migrations/0003_user_tour_done.py new file mode 100644 index 000000000..0f51cc5c2 --- /dev/null +++ b/timed/employment/migrations/0003_user_tour_done.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-25 07:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0002_auto_20170823_1051'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='tour_done', + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 55b62cf72..67576afab 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -74,6 +74,12 @@ class User(AbstractUser): supervisors = models.ManyToManyField('self', symmetrical=False, related_name='supervisees') + + tour_done = models.BooleanField(default=False) + """ + Indicate whether user has finished tour through Timed in frontend. + """ + objects = UserManager() diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 555f65425..44c5f170c 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -5,6 +5,7 @@ from dateutil import rrule from django.contrib.auth import get_user_model from django.utils.duration import duration_string +from rest_framework.exceptions import PermissionDenied from rest_framework_json_api import relations from rest_framework_json_api.serializers import (DurationField, IntegerField, ModelSerializer, @@ -168,6 +169,15 @@ def get_worktime_balance(self, instance): _, _, balance = self.get_worktime(instance, None, end_date) return duration_string(balance) + def validate(self, data): + user = self.context['request'].user + + # users may only change their own profile + if self.instance.id != user.id: + raise PermissionDenied() + + return data + included_serializers = { 'employments': 'timed.employment.serializers.EmploymentSerializer', @@ -189,6 +199,14 @@ class Meta: 'is_staff', 'is_active', 'user_absence_types', + 'tour_done' + ] + read_only_fields = [ + 'username', + 'first_name', + 'last_name', + 'is_staff', + 'is_active', ] diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index f8428d55b..f3d5b94d9 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.utils.duration import duration_string +from rest_framework import status from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) @@ -83,19 +84,45 @@ def test_user_create(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - def test_user_update(self): - """Should not be able to update an existing user.""" + def test_user_update_self(self): + """User may only change self.""" user = self.user + user.is_staff = False + user.save() + + data = { + 'data': { + 'type': 'users', + 'id': user.id, + 'attributes': { + 'is_staff': True, + 'tour_done': True + }, + } + } url = reverse('user-detail', args=[ user.id ]) noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) + res = self.client.patch(url, data=data) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + assert res.status_code == status.HTTP_200_OK + + user.refresh_from_db() + assert self.user.tour_done + assert not self.user.is_staff + + def test_user_update_other(self): + """User may not change other user.""" + url = reverse('user-detail', args=[ + self.users[0].id + ]) + res = self.client.patch(url) + + assert res.status_code == status.HTTP_403_FORBIDDEN def test_user_delete(self): """Should not be able delete a user.""" @@ -106,7 +133,7 @@ def test_user_delete(self): ]) noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) + res = self.client.delete(url) assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/views.py b/timed/employment/views.py index 2dec270a9..85b1b04de 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -1,23 +1,27 @@ """Viewsets for the employment app.""" from django.contrib.auth import get_user_model +from rest_framework import mixins, viewsets from rest_framework.viewsets import ReadOnlyModelViewSet from timed.employment import filters, models, serializers -class UserViewSet(ReadOnlyModelViewSet): - """User view set.""" +class UserViewSet(mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + """ + Expose user actions. + + Users are managed in admin therefore this end point + only allows retrieving and updating. + """ serializer_class = serializers.UserSerializer filter_class = filters.UserFilterSet def get_queryset(self): - """Filter the queryset by the user of the request. - - :return: The filtered users - :rtype: QuerySet - """ return get_user_model().objects.prefetch_related('employments') From aa698b45669b2bd8904d88c58a0bfb607af43144 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 25 Aug 2017 10:11:56 +0200 Subject: [PATCH 170/980] Show tour done flag in django admin --- timed/employment/admin.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index a6140d17c..7b4f30c9b 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -125,6 +125,16 @@ class UserAdmin(UserAdmin): 'enable_staff_status' ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fieldsets += ( + (_('Extra fields'), { + 'fields': [ + 'tour_done', + ], + }), + ) + def disable_users(self, request, queryset): queryset.update(is_active=False) disable_users.short_description = _('Disable selected users') From e800ffecf01026fb7c6f3c57faa345a7ef130f44 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Aug 2017 08:53:27 +0200 Subject: [PATCH 171/980] Updated to version 0.1.1 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 1487589d7..b2b0f04cb 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -__version__ = '0.1.0' +__version__ = '0.1.1' From 39ba42e3aa96429a51c05a89ce2e5fa29a8b74a9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Aug 2017 16:57:02 +0200 Subject: [PATCH 172/980] Only one new line per report line in Redmine Project report --- timed/redmine/templates/redmine/weekly_report.txt | 3 +-- timed/redmine/tests/test_redmine_report.py | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/timed/redmine/templates/redmine/weekly_report.txt index 031529222..72402a0d2 100644 --- a/timed/redmine/templates/redmine/weekly_report.txt +++ b/timed/redmine/templates/redmine/weekly_report.txt @@ -8,6 +8,5 @@ Total hours: {{total_hours}} Reported in last {{last_days}} days: {% for report in reports %} -{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment}} -{% endfor %} +{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment}}{% endfor %} diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 4074c6d6e..952ecba35 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -36,7 +36,11 @@ def test_redmine_report(db, freezer, mocker): 'value': report_hours }] assert 'Total hours: {0}'.format(report_hours) in issue.notes - assert 'Hours in last 7 days: {0}'.format(report_hours) in issue.notes + assert 'Hours in last 7 days: {0}\n'.format(report_hours) in issue.notes + assert '{0}\n'.format(report.comment) in issue.notes + assert '{0}\n\n'.format(report.comment) not in issue.notes, ( + 'Only one new line after report line' + ) issue.save.assert_called_once_with() From b8b59b8f6fe0f53399986236dddfc38e76931027 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 31 Aug 2017 10:51:34 +0200 Subject: [PATCH 173/980] Include templates in python package --- setup.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/setup.py b/setup.py index 03dc00165..326f98a5a 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,8 @@ """Setuptools package definition.""" import codecs +import os +from collections import defaultdict from setuptools import find_packages, setup @@ -9,6 +11,29 @@ with codecs.open('README.md', 'r', encoding='UTF-8') as f: README_TEXT = f.read() + +def find_data(packages, extensions): + """Find data files along with source. + + :param packages: Look in these packages + :param extensions: Look for these extensions + """ + data = defaultdict(list) + for package in packages: + package_path = package.replace('.', '/') + for dirpath, _, filenames in os.walk(package_path): + for filename in filenames: + for extension in extensions: + if filename.endswith('.%s' % extension): + file_path = os.path.join( + dirpath, + filename + ) + file_path = file_path[len(package) + 1:] + data[package].append(file_path) + return data + + setup( name='timed', version=__version__, @@ -38,6 +63,9 @@ keywords='timetracking', url='https://adfinis-sygroup.ch/', packages=find_packages(), + package_data=find_data( + find_packages(), ['txt'] + ), classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', From f8049b4db088c4721e18af53bef960e15968f876 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 30 Aug 2017 15:34:55 +0200 Subject: [PATCH 174/980] Add method to calculate worktime per employment --- timed/employment/models.py | 200 +++++++++++++++------- timed/employment/tests/test_employment.py | 148 ++++++++++++++-- timed/tracking/models.py | 3 +- 3 files changed, 279 insertions(+), 72 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 67576afab..ee2d6f4af 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -1,7 +1,8 @@ """Models for the employment app.""" -import datetime +from datetime import date, timedelta +from dateutil import rrule from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager from django.core.validators import MaxValueValidator, MinValueValidator @@ -10,27 +11,6 @@ from timed.models import WeekdaysField -class EmploymentManager(models.Manager): - """Custom manager for employments.""" - - def for_user(self, user, date=datetime.date.today()): - """Get the employment on a date for a user. - - :param User user: The user of the searched employment - :param datetime.date date: The date of the searched employment - :returns: The employment on the date for the user - :rtype: timed.employment.models.Employment - """ - return self.get( - ( - models.Q(end_date__gte=date) | - models.Q(end_date__isnull=True) - ), - start_date__lte=date, - user=user - ) - - class Location(models.Model): """Location model. @@ -83,42 +63,6 @@ class User(AbstractUser): objects = UserManager() -class Employment(models.Model): - """Employment model. - - An employment represents a contract which defines where an employee works - and from when to when. - """ - - user = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name='employments') - location = models.ForeignKey(Location, related_name='employments') - percentage = models.IntegerField(validators=[ - MinValueValidator(0), - MaxValueValidator(100)]) - worktime_per_day = models.DurationField() - start_date = models.DateField() - end_date = models.DateField(blank=True, null=True) - objects = EmploymentManager() - - def __str__(self): - """Represent the model as a string. - - :return: The string representation - :rtype: str - """ - return '{0} ({1} - {2})'.format( - self.user.username, - self.start_date.strftime('%d.%m.%Y'), - self.end_date.strftime('%d.%m.%Y') if self.end_date else 'today' - ) - - class Meta: - """Meta information for the employment model.""" - - indexes = [models.Index(fields=['start_date', 'end_date'])] - - class PublicHoliday(models.Model): """Public holiday model. @@ -293,3 +237,143 @@ def pk(self): class Meta: proxy = True + + +class EmploymentManager(models.Manager): + """Custom manager for employments.""" + + def for_user(self, user, date=date.today()): + """Get the employment on a date for a user. + + :param User user: The user of the searched employment + :param datetime.date date: The date of the searched employment + :returns: The employment on the date for the user + :rtype: timed.employment.models.Employment + """ + return self.get( + ( + models.Q(end_date__gte=date) | + models.Q(end_date__isnull=True) + ), + start_date__lte=date, + user=user + ) + + +class Employment(models.Model): + """Employment model. + + An employment represents a contract which defines where an employee works + and from when to when. + """ + + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='employments') + location = models.ForeignKey(Location, related_name='employments') + percentage = models.IntegerField(validators=[ + MinValueValidator(0), + MaxValueValidator(100)]) + worktime_per_day = models.DurationField() + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + objects = EmploymentManager() + + def __str__(self): + """Represent the model as a string. + + :return: The string representation + :rtype: str + """ + return '{0} ({1} - {2})'.format( + self.user.username, + self.start_date.strftime('%d.%m.%Y'), + self.end_date.strftime('%d.%m.%Y') if self.end_date else 'today' + ) + + def calculate_worktime(self, start, end): + """Calculate reported, expected and balance for employment. + + 1. It shortens the time frame so it is within given employment + 1. Determine the count of workdays within time frame + 2. Determine the count of public holidays within time frame + 3. The expected worktime consists of following elements: + * Workdays + * Subtracted by holidays + * Multiplicated with the worktime per day of the employment + 4. Determine the overtime credit duration within time frame + 5. The reported worktime is the sum of the durations of all reports + for this user within time frame + 6. The absences are all absences for this user within time frame + 7. The balance is the reported time plus the absences plus the + overtime credit minus the expected worktime + + :param start: calculate worktime starting on given day. + :param end: calculate worktime till given day + :returns: tuple of 3 values reported, expected and balance in given + time frame + """ + from timed.tracking.models import Absence, Report + + # shorten time frame to employment + start = max(start, self.start_date) + end = min(self.end_date or date.today(), end) + + # workdays is in isoweekday, byweekday expects Monday to be zero + week_workdays = [int(day) - 1 for day in self.location.workdays] + workdays = rrule.rrule( + rrule.DAILY, + dtstart=start, + until=end, + byweekday=week_workdays + ).count() + + # converting workdays as db expects 1 (Sunday) to 7 (Saturday) + workdays_db = [ + # special case for Sunday + int(day) == 7 and 1 or int(day) + 1 + for day in self.location.workdays + ] + holidays = PublicHoliday.objects.filter( + location=self.location, + date__gte=start, + date__lte=end, + date__week_day__in=workdays_db + ).count() + + expected_worktime = self.worktime_per_day * (workdays - holidays) + + overtime_credit = sum( + OvertimeCredit.objects.filter( + user=self.user, + date__gte=start, + date__lte=end + ).values_list('duration', flat=True), + timedelta() + ) + + reported_worktime = sum( + Report.objects.filter( + user=self.user, + date__gte=start, + date__lte=end + ).values_list('duration', flat=True), + timedelta() + ) + + absences = sum( + Absence.objects.filter( + user=self.user, + date__gte=start, + date__lte=end + ).values_list('duration', flat=True), + timedelta() + ) + + reported = reported_worktime + absences + overtime_credit + + return (reported, expected_worktime, reported - expected_worktime) + + class Meta: + """Meta information for the employment model.""" + + indexes = [models.Index(fields=['start_date', 'end_date'])] diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 3edae1760..da1b992fe 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -1,16 +1,17 @@ """Tests for the employments endpoint.""" -import datetime +from datetime import date, timedelta import pytest from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) +from timed.employment import factories from timed.employment.admin import EmploymentForm -from timed.employment.factories import EmploymentFactory from timed.employment.models import Employment from timed.jsonapi_test_case import JSONAPITestCase +from timed.tracking.factories import ReportFactory class EmploymentTests(JSONAPITestCase): @@ -24,14 +25,18 @@ def setUp(self): super().setUp() self.employments = [ - EmploymentFactory.create(user=self.user, - start_date=datetime.date(2010, 1, 1), - end_date=datetime.date(2015, 1, 1)), - EmploymentFactory.create(user=self.user, - start_date=datetime.date(2015, 1, 2)) + factories.EmploymentFactory.create( + user=self.user, + start_date=date(2010, 1, 1), + end_date=date(2015, 1, 1) + ), + factories.EmploymentFactory.create( + user=self.user, + start_date=date(2015, 1, 2) + ) ] - EmploymentFactory.create_batch(10) + factories.EmploymentFactory.create_batch(10) def test_employment_list(self): """Should respond with a list of employments.""" @@ -111,8 +116,8 @@ def test_employment_unique_active(self): def test_employment_unique_range(self): """Should only be able to have one employment at a time per user.""" form = EmploymentForm({ - 'start_date': datetime.date(2009, 1, 1), - 'end_date': datetime.date(2016, 1, 1) + 'start_date': date(2009, 1, 1), + 'end_date': date(2016, 1, 1) }, instance=self.employments[0]) with pytest.raises(ValueError): @@ -130,7 +135,7 @@ def test_employment_at(self): employment.end_date = ( employment.start_date + - datetime.timedelta(days=20) + timedelta(days=20) ) employment.save() @@ -138,5 +143,124 @@ def test_employment_at(self): with pytest.raises(Employment.DoesNotExist): Employment.objects.for_user( self.user, - employment.start_date + datetime.timedelta(days=21) + employment.start_date + timedelta(days=21) ) + + +def test_worktime_balance_partial(db): + """ + Test partial calculation of worktime balance. + + Partial is defined as a worktime balance of a time frame + which is shorter than employment. + """ + employment = factories.EmploymentFactory.create( + start_date=date(2010, 1, 1), + end_date=None, + worktime_per_day=timedelta(hours=8) + ) + user = employment.user + + # Calculate over one week + start = date(2017, 3, 19) + end = date(2017, 3, 26) + + # Overtime credit of 10.5 hours + factories.OvertimeCreditFactory.create( + user=user, + date=start, + duration=timedelta(hours=10, minutes=30) + ) + + # One public holiday during workdays + factories.PublicHolidayFactory.create( + date=start, + location=employment.location + ) + # One public holiday on weekend + factories.PublicHolidayFactory.create( + date=start + timedelta(days=1), + location=employment.location + ) + # 5 workdays minus one holiday (32 hours) + expected_expected = timedelta(hours=32) + + # reported 2 days each 10 hours + for day in range(3, 5): + ReportFactory.create( + user=user, + date=start + timedelta(days=day), + duration=timedelta(hours=10) + ) + # 10 hours reported time + 10.5 overtime credit + expected_reported = timedelta(hours=30, minutes=30) + expected_balance = expected_reported - expected_expected + + reported, expected, balance = employment.calculate_worktime(start, end) + + assert expected == expected_expected + assert reported == expected_reported + assert balance == expected_balance + + +def test_worktime_balance_longer(db): + """Test calculation of worktime when frame is longer than employment.""" + employment = factories.EmploymentFactory.create( + start_date=date(2017, 3, 21), + end_date=date(2017, 3, 27), + worktime_per_day=timedelta(hours=8) + ) + user = employment.user + + # Calculate over one year + start = date(2017, 1, 1) + end = date(2017, 12, 31) + + # Overtime credit of 10.5 hours before employment + factories.OvertimeCreditFactory.create( + user=user, + date=start, + duration=timedelta(hours=10, minutes=30) + ) + # Overtime credit of during employment + factories.OvertimeCreditFactory.create( + user=user, + date=employment.start_date, + duration=timedelta(hours=10, minutes=30) + ) + + # One public holiday during employment + factories.PublicHolidayFactory.create( + date=employment.start_date, + location=employment.location + ) + # One public holiday before employment started + factories.PublicHolidayFactory.create( + date=date(2017, 3, 20), + location=employment.location + ) + # 5 workdays minus one holiday (32 hours) + expected_expected = timedelta(hours=32) + + # reported 2 days each 10 hours + for day in range(3, 5): + ReportFactory.create( + user=user, + date=employment.start_date + timedelta(days=day), + duration=timedelta(hours=10) + ) + # reported time not on current employment + ReportFactory.create( + user=user, + date=date(2017, 1, 5), + duration=timedelta(hours=10) + ) + # 10 hours reported time + 10.5 overtime credit + expected_reported = timedelta(hours=30, minutes=30) + expected_balance = expected_reported - expected_expected + + reported, expected, balance = employment.calculate_worktime(start, end) + + assert expected == expected_expected + assert reported == expected_reported + assert balance == expected_balance diff --git a/timed/tracking/models.py b/timed/tracking/models.py index bbbbe2cf5..03f49c868 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -6,8 +6,6 @@ from django.db import models from django.db.models import F, Sum -from timed.employment.models import Employment - class Activity(models.Model): """Activity model. @@ -188,6 +186,7 @@ def save(self, *args, **kwargs): sickness), in which case the duration of the absence needs to fill the difference between the reported time and the worktime per day. """ + from timed.employment.models import Employment employment = Employment.objects.for_user(self.user, self.date) if self.type.fill_worktime: From 8424ca1e6d75e14b86b57d30ec637dfdde9eda65 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 30 Aug 2017 16:19:01 +0200 Subject: [PATCH 175/980] Add ability to retrieve employment on given day and in time range --- timed/employment/models.py | 28 ++++++++++++++++++----- timed/employment/serializers.py | 12 ++-------- timed/employment/tests/test_employment.py | 6 ++--- timed/tracking/factories.py | 2 +- timed/tracking/models.py | 2 +- timed/tracking/serializers.py | 2 +- timed/tracking/tests/test_absence.py | 4 ++-- 7 files changed, 32 insertions(+), 24 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index ee2d6f4af..04ab0acfa 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -242,13 +242,12 @@ class Meta: class EmploymentManager(models.Manager): """Custom manager for employments.""" - def for_user(self, user, date=date.today()): - """Get the employment on a date for a user. + def get_at(self, user, date): + """Get employment of user at given date. - :param User user: The user of the searched employment - :param datetime.date date: The date of the searched employment - :returns: The employment on the date for the user - :rtype: timed.employment.models.Employment + :param User user: The user of the searched employments + :param datetime.date date: date of employment + :returns: Employment """ return self.get( ( @@ -259,6 +258,23 @@ def for_user(self, user, date=date.today()): user=user ) + def for_user(self, user, start, end): + """Get employments in given time frame for current user. + + :param User user: The user of the searched employments + :param datetime.date start: start of time frame + :param datetime.date end: end of time frame + :returns: queryset of employments + """ + return self.filter( + ( + models.Q(end_date__gte=end) | + models.Q(end_date__isnull=True) + ), + start_date__lte=start, + user=user + ) + class Employment(models.Model): """Employment model. diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 44c5f170c..0a48d8958 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -42,15 +42,7 @@ def get_user_absence_types(self, instance): ), '%Y-%m-%d' ).date() - - try: - employment = models.Employment.objects.for_user(instance, end) - except models.Employment.DoesNotExist: - return models.UserAbsenceType.objects.none() - - start = max( - employment.start_date, date(date.today().year, 1, 1) - ) + start = date(end.year, 1, 1) return models.UserAbsenceType.objects.with_user(instance, start, @@ -89,7 +81,7 @@ def get_worktime(self, user, start=None, end=None): end = end or date.today() try: - employment = models.Employment.objects.for_user(user, end) + employment = models.Employment.objects.get_at(user, end) except models.Employment.DoesNotExist: # If there is no active employment, set the balance to 0 return timedelta(), timedelta(), timedelta() diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index da1b992fe..1b6962052 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -123,13 +123,13 @@ def test_employment_unique_range(self): with pytest.raises(ValueError): form.save() - def test_employment_at(self): + def test_employment_get_at(self): """Should return the right employment on a date.""" employment = Employment.objects.get(user=self.user, end_date__isnull=True) assert ( - Employment.objects.for_user(self.user, employment.start_date) == + Employment.objects.get_at(self.user, employment.start_date) == employment ) @@ -141,7 +141,7 @@ def test_employment_at(self): employment.save() with pytest.raises(Employment.DoesNotExist): - Employment.objects.for_user( + Employment.objects.get_at( self.user, employment.start_date + timedelta(days=21) ) diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index f16952def..03a0cd21c 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -108,7 +108,7 @@ def duration(self): :return: The computed duration :rtype: datetime.timedelta """ - return Employment.objects.for_user( + return Employment.objects.get_at( self.user, self.date ).worktime_per_day diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 03f49c868..3b37374cb 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -187,7 +187,7 @@ def save(self, *args, **kwargs): difference between the reported time and the worktime per day. """ from timed.employment.models import Employment - employment = Employment.objects.for_user(self.user, self.date) + employment = Employment.objects.get_at(self.user, self.date) if self.type.fill_worktime: worktime = sum( diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index b381a96a2..0d5ad0911 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -202,7 +202,7 @@ def validate(self, data): :returns: The validated data :rtype: dict """ - location = Employment.objects.for_user( + location = Employment.objects.get_at( data.get('user'), data.get('date') ).location diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 4618b1781..2677f35ca 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -170,7 +170,7 @@ def test_absence_fill_worktime(self): """Should create an absence which fills the worktime.""" date = datetime.date(2017, 5, 10) type = AbsenceTypeFactory.create(fill_worktime=True) - employment = Employment.objects.for_user(self.user, date) + employment = Employment.objects.get_at(self.user, date) employment.worktime_per_day = datetime.timedelta(hours=8) employment.save() @@ -244,7 +244,7 @@ def test_absence_public_holiday(self): type = AbsenceTypeFactory.create() PublicHolidayFactory.create( - location=Employment.objects.for_user(self.user, date).location, + location=Employment.objects.get_at(self.user, date).location, date=date ) From ce963157ccc0146d228222b4cb3d2340e4c36211 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 31 Aug 2017 13:29:07 +0200 Subject: [PATCH 176/980] Calculation of time balance needs to be across employments --- timed/employment/models.py | 101 +++++++----- timed/employment/serializers.py | 103 +------------ timed/employment/tests/test_employment.py | 34 +++++ timed/employment/tests/test_user.py | 178 ++++++++++++---------- 4 files changed, 198 insertions(+), 218 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 04ab0acfa..f9cc7f55f 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import AbstractUser, UserManager from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import functions from timed.models import WeekdaysField @@ -32,37 +33,6 @@ def __str__(self): return self.name -class UserManager(UserManager): - def all_supervisors(self): - objects = self.model.objects.annotate( - supervisees_count=models.Count('supervisees')) - return objects.filter(supervisees_count__gt=0) - - def all_reviewers(self): - objects = self.model.objects.annotate( - reviews_count=models.Count('reviews')) - return objects.filter(reviews__gt=0) - - def all_supervisees(self): - objects = self.model.objects.annotate( - supervisors_count=models.Count('supervisors')) - return objects.filter(supervisors_count__gt=0) - - -class User(AbstractUser): - """Timed specific user.""" - - supervisors = models.ManyToManyField('self', symmetrical=False, - related_name='supervisees') - - tour_done = models.BooleanField(default=False) - """ - Indicate whether user has finished tour through Timed in frontend. - """ - - objects = UserManager() - - class PublicHoliday(models.Model): """Public holiday model. @@ -261,17 +231,23 @@ def get_at(self, user, date): def for_user(self, user, start, end): """Get employments in given time frame for current user. + This includes overlapping employments. + :param User user: The user of the searched employments :param datetime.date start: start of time frame :param datetime.date end: end of time frame :returns: queryset of employments """ - return self.filter( + # end date NULL on database is like employment is ending today + queryset = self.annotate( + end=functions.Coalesce('end_date', models.Value(date.today())) + ) + return queryset.filter( + # using end field as defined as Coalesce above ( - models.Q(end_date__gte=end) | - models.Q(end_date__isnull=True) + models.Q(start_date__range=[start, end]) | + models.Q(end__range=[start, end]) ), - start_date__lte=start, user=user ) @@ -393,3 +369,58 @@ class Meta: """Meta information for the employment model.""" indexes = [models.Index(fields=['start_date', 'end_date'])] + + +class UserManager(UserManager): + def all_supervisors(self): + objects = self.model.objects.annotate( + supervisees_count=models.Count('supervisees')) + return objects.filter(supervisees_count__gt=0) + + def all_reviewers(self): + objects = self.model.objects.annotate( + reviews_count=models.Count('reviews')) + return objects.filter(reviews__gt=0) + + def all_supervisees(self): + objects = self.model.objects.annotate( + supervisors_count=models.Count('supervisors')) + return objects.filter(supervisors_count__gt=0) + + +class User(AbstractUser): + """Timed specific user.""" + + supervisors = models.ManyToManyField('self', symmetrical=False, + related_name='supervisees') + + tour_done = models.BooleanField(default=False) + """ + Indicate whether user has finished tour through Timed in frontend. + """ + + objects = UserManager() + + def calculate_worktime(self, start, end): + """Calculate reported, expected and balance for user. + + This calculates summarizes worktime for all employments of users which + are in given time frame. + + :param start: calculate worktime starting on given day. + :param end: calculate worktime till given day + :returns: tuple of 3 values reported, expected and balance in given + time frame + """ + employments = Employment.objects.for_user(self, start, end) + + balances = [ + employment.calculate_worktime(start, end) + for employment in employments + ] + + reported = sum([balance[0] for balance in balances], timedelta()) + expected = sum([balance[1] for balance in balances], timedelta()) + balance = sum([balance[2] for balance in balances], timedelta()) + + return (reported, expected, balance) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 0a48d8958..ac8341466 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -1,8 +1,7 @@ """Serializers for the employment app.""" -from datetime import date, datetime, timedelta +from datetime import date, datetime -from dateutil import rrule from django.contrib.auth import get_user_model from django.utils.duration import duration_string from rest_framework.exceptions import PermissionDenied @@ -12,7 +11,6 @@ SerializerMethodField) from timed.employment import models -from timed.tracking.models import Absence, Report class UserSerializer(ModelSerializer): @@ -49,104 +47,11 @@ def get_user_absence_types(self, instance): end) def get_worktime(self, user, start=None, end=None): - """Calculate the reported, expected and balance for user. - - 1. Determine the current employment of the user - 2. Take the latest of those two as start date: - * The start of the year - * The start of the current employment - 3. Take the delivered date if given or the current date as end date - 4. Determine the count of workdays within start and end date - 5. Determine the count of public holidays within start and end date - 6. The expected worktime consists of following elements: - * Workdays - * Subtracted by holidays - * Multiplicated with the worktime per day of the employment - 7. Determine the overtime credit duration within start and end date - 8. The reported worktime is the sum of the durations of all reports - for this user within start and end date - 9. The absences are all absences for this user between the start and - end time - 10. The balance is the reported time plus the absences plus the - overtime credit minus the expected worktime - - :param user: user to get worktime from - :param start_date: worktime starting on given day; - if not set when employment started resp. begining of - the year - :param end_date: worktime till day or if not set today - :returns: tuple of 3 values reported, expected and balance in given - time frame - """ end = end or date.today() + start = date(end.year, 1, 1) - try: - employment = models.Employment.objects.get_at(user, end) - except models.Employment.DoesNotExist: - # If there is no active employment, set the balance to 0 - return timedelta(), timedelta(), timedelta() - - location = employment.location - - if start is None: - start = max( - employment.start_date, date(date.today().year, 1, 1) - ) - - # workdays is in isoweekday, byweekday expects Monday to be zero - week_workdays = [int(day) - 1 for day in employment.location.workdays] - workdays = rrule.rrule( - rrule.DAILY, - dtstart=start, - until=end, - byweekday=week_workdays - ).count() - - # converting workdays as db expects 1 (Sunday) to 7 (Saturday) - workdays_db = [ - # special case for Sunday - int(day) == 7 and 1 or int(day) + 1 - for day in location.workdays - ] - holidays = models.PublicHoliday.objects.filter( - location=location, - date__gte=start, - date__lte=end, - date__week_day__in=workdays_db - ).count() - - expected_worktime = employment.worktime_per_day * (workdays - holidays) - - overtime_credit = sum( - models.OvertimeCredit.objects.filter( - user=user, - date__gte=start, - date__lte=end - ).values_list('duration', flat=True), - timedelta() - ) - - reported_worktime = sum( - Report.objects.filter( - user=user, - date__gte=start, - date__lte=end - ).values_list('duration', flat=True), - timedelta() - ) - - absences = sum( - Absence.objects.filter( - user=user, - date__gte=start, - date__lte=end - ).values_list('duration', flat=True), - timedelta() - ) - - reported = reported_worktime + absences + overtime_credit - - return (reported, expected_worktime, reported - expected_worktime) + balance_tuple = user.calculate_worktime(start, end) + return balance_tuple def get_worktime_balance(self, instance): """Format the worktime balance. diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 1b6962052..8e2fc3d61 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -264,3 +264,37 @@ def test_worktime_balance_longer(db): assert expected == expected_expected assert reported == expected_reported assert balance == expected_balance + + +def test_employment_for_user(db): + user = factories.UserFactory.create() + # employment overlapping time frame (early start) + factories.EmploymentFactory.create( + start_date=date(2017, 1, 1), + end_date=date(2017, 2, 28), + user=user + ) + # employment overlapping time frame (early end) + factories.EmploymentFactory.create( + start_date=date(2017, 3, 1), + end_date=date(2017, 3, 31), + user=user + ) + # employment within time frame + factories.EmploymentFactory.create( + start_date=date(2017, 4, 1), + end_date=date(2017, 4, 30), + user=user + ) + # employment without end date + factories.EmploymentFactory.create( + start_date=date(2017, 5, 1), + end_date=None, + user=user + ) + + employments = Employment.objects.for_user( + user, date(2017, 2, 1), date(2017, 12, 1) + ) + + assert employments.count() == 4 diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index f3d5b94d9..3f3bb8f5f 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -138,90 +138,6 @@ def test_user_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - def test_user_worktime_balance(self): - """Should calculate correct worktime balances.""" - user = self.user - employment = user.employments.get(end_date__isnull=True) - - # Calculate over one week - start_date = date(2017, 3, 19) - end_date = date(2017, 3, 26) - - employment.start_date = start_date - employment.worktime_per_day = timedelta(hours=8) - - employment.save() - - # Overtime credit of 10 hours - OvertimeCreditFactory.create( - user=user, - date=start_date, - duration=timedelta(hours=10, minutes=30) - ) - - # One public holiday during workdays - PublicHolidayFactory.create( - date=start_date, - location=employment.location - ) - # One public holiday on weekend - PublicHolidayFactory.create( - date=start_date + timedelta(days=1), - location=employment.location - ) - - url = reverse('user-detail', args=[ - user.id - ]) - - res = self.client.get('{0}?until={1}'.format( - url, - end_date.strftime('%Y-%m-%d') - )) - - result = self.result(res) - - # 5 workdays minus one holiday minus 10 hours overtime credit - expected_worktime = ( - 4 * employment.worktime_per_day - - timedelta(hours=10, minutes=30) - ) - - assert ( - result['data']['attributes']['worktime-balance'] == - duration_string(timedelta() - expected_worktime) - ) - - # 2x 10 hour reported worktime - ReportFactory.create( - user=user, - date=start_date + timedelta(days=3), - duration=timedelta(hours=10) - ) - - ReportFactory.create( - user=user, - date=start_date + timedelta(days=4), - duration=timedelta(hours=10) - ) - - AbsenceFactory.create( - user=user, - date=start_date + timedelta(days=5) - ) - - res2 = self.client.get('{0}?until={1}'.format( - url, - end_date.strftime('%Y-%m-%d') - )) - - result2 = self.result(res2) - - assert ( - result2['data']['attributes']['worktime-balance'] == - duration_string(timedelta(hours=28) - expected_worktime) - ) - def test_user_without_employment(self): user = get_user_model().objects.create_user(username='test', password='1234qwer') @@ -333,3 +249,97 @@ def test_user_absence_types_fill_worktime(self): assert inc[0]['attributes']['balance'] is None assert inc[0]['attributes']['used-days'] is None assert inc[0]['attributes']['used-duration'] == '06:00:00' + + +def test_user_worktime_balance(auth_client): + """Should calculate correct worktime balances.""" + # Calculate over one week + start_date = date(2017, 3, 19) + end_date = date(2017, 3, 26) + + user = auth_client.user + employment = EmploymentFactory.create( + user=user, + start_date=start_date, + worktime_per_day=timedelta(hours=8, minutes=30), + end_date=date(2017, 3, 23) + ) + employment_short = EmploymentFactory.create( + user=user, + start_date=date(2017, 3, 24), + worktime_per_day=timedelta(hours=8), + end_date=None + ) + + # Overtime credit of 10 hours + OvertimeCreditFactory.create( + user=user, + date=start_date, + duration=timedelta(hours=10, minutes=30) + ) + + # One public holiday during workdays + PublicHolidayFactory.create( + date=start_date, + location=employment.location + ) + # One public holiday on weekend + PublicHolidayFactory.create( + date=start_date + timedelta(days=1), + location=employment.location + ) + + url = reverse('user-detail', args=[ + user.id + ]) + + res = auth_client.get('{0}?until={1}'.format( + url, + end_date.strftime('%Y-%m-%d') + )) + + result = res.json() + + # Calculated result: + # 4 workdays 8.5 hours, 1 workday 8 hours, minus one holiday 8.5 + # minutes 10.5 hours overtime credit + expected_worktime = ( + 1 * employment_short.worktime_per_day + + 3 * employment.worktime_per_day - + timedelta(hours=10, minutes=30) + ) + + assert ( + result['data']['attributes']['worktime-balance'] == + duration_string(timedelta() - expected_worktime) + ) + + # 2x 10 hour reported worktime + ReportFactory.create( + user=user, + date=start_date + timedelta(days=3), + duration=timedelta(hours=10) + ) + + ReportFactory.create( + user=user, + date=start_date + timedelta(days=4), + duration=timedelta(hours=10) + ) + + AbsenceFactory.create( + user=user, + date=start_date + timedelta(days=5) + ) + + res2 = auth_client.get('{0}?until={1}'.format( + url, + end_date.strftime('%Y-%m-%d') + )) + + result2 = res2.json() + + assert ( + result2['data']['attributes']['worktime-balance'] == + duration_string(timedelta(hours=28) - expected_worktime) + ) From 2dfe64e415ae36c4b19cd7183393358e0c16176f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 31 Aug 2017 14:05:42 +0200 Subject: [PATCH 177/980] Solve overlapping employments Employments which have a shorter start and a longer end and searched time frame --- timed/employment/models.py | 7 ++----- .../management/commands/notify_supervisors_shorttime.py | 8 +------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index f9cc7f55f..619ec0fd5 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -243,12 +243,9 @@ def for_user(self, user, start, end): end=functions.Coalesce('end_date', models.Value(date.today())) ) return queryset.filter( - # using end field as defined as Coalesce above - ( - models.Q(start_date__range=[start, end]) | - models.Q(end__range=[start, end]) - ), user=user + ).exclude( + models.Q(end__lt=start) | models.Q(start_date__gt=end) ) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index 80f9c8dda..ef8b88678 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -6,8 +6,6 @@ from django.core.management.base import BaseCommand from django.template.loader import render_to_string -from timed.employment.serializers import UserSerializer - class Command(BaseCommand): """ @@ -29,10 +27,6 @@ class Command(BaseCommand): help = 'Notify supervisors when supervisees have reported shortime.' - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.serializer = UserSerializer() - def add_arguments(self, parser): parser.add_argument( '--days', @@ -85,7 +79,7 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): supervisees_shorttime = {} supervisees = get_user_model().objects.all_supervisees() for supervisee in supervisees: - worktime = self.serializer.get_worktime(supervisee, start, end) + worktime = supervisee.calculate_worktime(start, end) reported, expected, balance = worktime if expected == timedelta(0): continue From 712ae8b10f7b37cd6d12d0db09551467137abae2 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 31 Aug 2017 16:17:22 +0200 Subject: [PATCH 178/980] Default tasks need to be saved on new projects As default task were marked as not changed those were not saved when saving project. --- timed/projects/admin.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 11d7f2780..4d36492be 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -1,5 +1,6 @@ """Views for the admin interface.""" +from django import forms from django.contrib import admin from django.forms.models import BaseInlineFormSet from django.utils.translation import ugettext_lazy as _ @@ -24,25 +25,41 @@ class BillingType(admin.ModelAdmin): search_fields = ['name'] +class TaskForm(forms.ModelForm): + """ + Task form making sure that initial forms are marked as changed. + + Otherwise when saving project default tasks would not be saved. + """ + + model = models.Task + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + initial = kwargs.get('initial') + if initial: + self.changed_data = ['name'] + + class TaskInlineFormset(BaseInlineFormSet): """Task formset defaulting to task templates when project is created.""" def __init__(self, *args, **kwargs): - kwargs['initial'] = [ - {'name': tmpl.name} - for tmpl in models.TaskTemplate.objects.order_by('name') - ] super().__init__(*args, **kwargs) + project = kwargs['instance'] + if project.tasks.count() == 0: + self.initial = [ + {'name': tmpl.name} + for tmpl in models.TaskTemplate.objects.order_by('name') + ] + self.extra += len(self.initial) class TaskInline(admin.TabularInline): formset = TaskInlineFormset + form = TaskForm model = models.Task - - def get_extra(self, request, obj=None, **kwargs): - if obj is not None: - return 0 - return models.TaskTemplate.objects.count() + extra = 0 def has_delete_permission(self, request, obj=None): # for some reason obj is parent object and not task From 424cbee1a1ce08d601935f87b27f18ef47ad0f0d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 31 Aug 2017 16:27:14 +0200 Subject: [PATCH 179/980] Default ordering of project models is name This ordering is used as default for django admin and api end points --- .../migrations/0003_auto_20170831_1624.py | 31 +++++++++++++++++++ timed/projects/models.py | 6 ++++ 2 files changed, 37 insertions(+) create mode 100644 timed/projects/migrations/0003_auto_20170831_1624.py diff --git a/timed/projects/migrations/0003_auto_20170831_1624.py b/timed/projects/migrations/0003_auto_20170831_1624.py new file mode 100644 index 000000000..a3bf4b77e --- /dev/null +++ b/timed/projects/migrations/0003_auto_20170831_1624.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 14:24 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_auto_20170823_1045'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customer', + options={'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='project', + options={'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='task', + options={'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='tasktemplate', + options={'ordering': ['name']}, + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 621f3afcf..dcb212c9a 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -29,6 +29,7 @@ class Meta: """Meta informations for the customer model.""" indexes = [models.Index(fields=['name', 'archived'])] + ordering = ['name'] class BillingType(models.Model): @@ -71,6 +72,7 @@ class Meta: """Meta informations for the project model.""" indexes = [models.Index(fields=['name', 'archived'])] + ordering = ['name'] class Task(models.Model): @@ -98,6 +100,7 @@ class Meta: """Meta informations for the task model.""" indexes = [models.Index(fields=['name', 'archived'])] + ordering = ['name'] class TaskTemplate(models.Model): @@ -116,3 +119,6 @@ def __str__(self): :rtype: str """ return self.name + + class Meta: + ordering = ['name'] From 6d4384491d220826d973eb53efdff6b5b9233d6e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 7 Jul 2017 16:30:42 +0200 Subject: [PATCH 180/980] Add spent hours onto project end point --- timed/projects/serializers.py | 4 +++- timed/projects/tests/test_project.py | 26 ++++++++++++++------------ timed/projects/views.py | 3 +++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 581856dad..4c91b9ab9 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -1,7 +1,7 @@ """Serializers for the projects app.""" from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import ModelSerializer +from rest_framework_json_api.serializers import DurationField, ModelSerializer from timed.projects import models @@ -35,6 +35,7 @@ class ProjectSerializer(ModelSerializer): billing_type = ResourceRelatedField( queryset=models.BillingType.objects.all() ) + spent_hours = DurationField(read_only=True) included_serializers = { 'customer': 'timed.projects.serializers.CustomerSerializer', @@ -49,6 +50,7 @@ class Meta: 'name', 'comment', 'estimated_hours', + 'spent_hours', 'archived', 'customer', 'billing_type' diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index aa08ffe17..991fb7b4a 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,11 +1,13 @@ """Tests for the projects endpoint.""" +from datetime import timedelta from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) from timed.jsonapi_test_case import JSONAPITestCase -from timed.projects.factories import ProjectFactory +from timed.projects.factories import ProjectFactory, TaskFactory +from timed.tracking.factories import ReportFactory class ProjectTests(JSONAPITestCase): @@ -18,7 +20,7 @@ def setUp(self): """Set the environment for the tests up.""" super().setUp() - self.projects = ProjectFactory.create_batch(10) + self.project = ProjectFactory.create() ProjectFactory.create_batch( 10, @@ -27,6 +29,9 @@ def setUp(self): def test_project_list(self): """Should respond with a list of projects.""" + task = TaskFactory.create(project=self.project) + ReportFactory.create_batch(10, task=task, duration=timedelta(hours=1)) + url = reverse('project-list') noauth_res = self.noauth_client.get(url) @@ -37,14 +42,15 @@ def test_project_list(self): result = self.result(res) - assert len(result['data']) == len(self.projects) + assert len(result['data']) == 1 + assert result['data'][0]['attributes']['spent-hours'] == ( + '10:00:00' + ) def test_project_detail(self): """Should respond with a single project.""" - project = self.projects[0] - url = reverse('project-detail', args=[ - project.id + self.project.id ]) noauth_res = self.noauth_client.get(url) @@ -65,10 +71,8 @@ def test_project_create(self): def test_project_update(self): """Should not be able to update an existing project.""" - project = self.projects[0] - url = reverse('project-detail', args=[ - project.id + self.project.id ]) noauth_res = self.noauth_client.patch(url) @@ -79,10 +83,8 @@ def test_project_update(self): def test_project_delete(self): """Should not be able to delete a project.""" - project = self.projects[0] - url = reverse('project-detail', args=[ - project.id + self.project.id ]) noauth_res = self.noauth_client.delete(url) diff --git a/timed/projects/views.py b/timed/projects/views.py index 313ef0b2c..ab60501ef 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,5 +1,6 @@ """Viewsets for the projects app.""" +from django.db.models import Sum from rest_framework.viewsets import ReadOnlyModelViewSet from timed.projects import filters, models, serializers @@ -46,6 +47,8 @@ def get_queryset(self): """ return models.Project.objects.select_related( 'customer' + ).annotate( + spent_hours=Sum('customer__projects__tasks__reports__duration') ) From ebf7e1f6270f76a55c88df5f478f3e89aac40614 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 4 Sep 2017 14:57:15 +0200 Subject: [PATCH 181/980] Add total hours to reports end point Total hours is included in meta, only calculated on list view and across all results not just page. --- timed/tracking/serializers.py | 12 ++++++++++++ timed/tracking/tests/test_report.py | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 0d5ad0911..7a0e3854a 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -1,6 +1,8 @@ """Serializers for the tracking app.""" from django.contrib.auth import get_user_model +from django.db.models import Sum +from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, @@ -165,6 +167,16 @@ def validate_duration(self, value): return value + def get_root_meta(self, resource, many): + """Add total hours over whole result (not just page) to meta.""" + if many: + view = self.context['view'] + queryset = view.filter_queryset(view.get_queryset()) + data = queryset.aggregate(total_hours=Sum('duration')) + data['total_hours'] = duration_string(data['total_hours']) + return data + return {} + class Meta: """Meta information for the report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index f69656437..d5da167b7 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -35,7 +35,8 @@ def setUp(self): password='123qweasd' ) - self.reports = ReportFactory.create_batch(10, user=self.user) + self.reports = ReportFactory.create_batch(10, user=self.user, + duration=timedelta(hours=1)) self.other_reports = ReportFactory.create_batch(10, user=other_user) def test_report_list(self): @@ -61,6 +62,7 @@ def test_report_list(self): assert len(result['data']) == 1 assert result['data'][0]['id'] == str(self.reports[0].id) + assert result['meta']['total-hours'] == '01:00:00' def test_report_list_filter_reviewer(self): report = self.reports[0] @@ -90,6 +92,7 @@ def test_report_list_verify(self): assert res.status_code == HTTP_200_OK result = self.result(res) assert len(result['data']) == 10 + assert result['meta']['total-hours'] == '10:00:00' def test_report_list_verify_non_admin(self): """Non admin resp. non staff user may not verify reports.""" From 10a7e77bf994c33516d433a4ef17805f1410b0d7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 4 Sep 2017 15:13:52 +0200 Subject: [PATCH 182/980] Add possibility to add minus absence credit This is needed e.g. when last year someone has taken too many holidays and this need to be rolled over to new year. As this is in same migration also added default ordering to relevant employment fields. --- .../migrations/0004_auto_20170904_1510.py | 32 +++++++++++++++++++ timed/employment/models.py | 9 +++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 timed/employment/migrations/0004_auto_20170904_1510.py diff --git a/timed/employment/migrations/0004_auto_20170904_1510.py b/timed/employment/migrations/0004_auto_20170904_1510.py new file mode 100644 index 000000000..52467d3f2 --- /dev/null +++ b/timed/employment/migrations/0004_auto_20170904_1510.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-04 13:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0003_user_tour_done'), + ] + + operations = [ + migrations.AlterModelOptions( + name='absencetype', + options={'ordering': ('name',)}, + ), + migrations.AlterModelOptions( + name='location', + options={'ordering': ('name',)}, + ), + migrations.AlterModelOptions( + name='publicholiday', + options={'ordering': ('date',)}, + ), + migrations.AlterField( + model_name='absencecredit', + name='days', + field=models.IntegerField(default=0), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 619ec0fd5..4556dbd79 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -32,6 +32,9 @@ def __str__(self): """ return self.name + class Meta: + ordering = ('name', ) + class PublicHoliday(models.Model): """Public holiday model. @@ -57,6 +60,7 @@ class Meta: """Meta information for the public holiday model.""" indexes = [models.Index(fields=['date'])] + ordering = ('date', ) class AbsenceType(models.Model): @@ -77,6 +81,9 @@ def __str__(self): """ return self.name + class Meta: + ordering = ('name', ) + class AbsenceCredit(models.Model): """Absence credit model. @@ -92,7 +99,7 @@ class AbsenceCredit(models.Model): absence_type = models.ForeignKey(AbsenceType, related_name='absence_credits') date = models.DateField() - days = models.PositiveIntegerField(default=0) + days = models.IntegerField(default=0) class OvertimeCredit(models.Model): From 56d13a2bf412411d95ebac59e34a5dda1cd26ba0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 4 Sep 2017 15:58:59 +0200 Subject: [PATCH 183/980] Add template engine used for plain text templates This is needed to avoid escaping in mail text. --- .../commands/notify_reviewers_unverified.py | 2 +- .../commands/notify_supervisors_shorttime.py | 2 +- timed/settings.py | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index 20027c7eb..0bad13220 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -98,7 +98,7 @@ def _notify_reviewers(self, start, end, reports): 'reviewer': reviewer, 'protocol': settings.HOST_PROTOCOL, 'domain': settings.HOST_DOMAIN, - } + }, using='text' ) mails.append((subject, body, from_email, [reviewer.email])) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index ef8b88678..a83843dcb 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -123,7 +123,7 @@ def _notify_supervisors(self, start, end, ratio, supervisees): # format: # [(user, (reported, expected, balance, ratio)), ...] 'suspects': suspects_shorttime - } + }, using='text' ) mails.append((subject, body, from_email, [supervisor.email])) diff --git a/timed/settings.py b/timed/settings.py index 6314355c9..51b91901a 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -86,6 +86,22 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ], }, }, + # template backend for plain text (no escaping) + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [django_root('timed', 'templates')], + 'NAME': 'text', + 'APP_DIRS': True, + 'OPTIONS': { + 'autoescape': False, + '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 = 'timed.wsgi.application' From a33f25a75d1c62fce6507da9dff4ec0a198a4358 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 4 Sep 2017 16:05:57 +0200 Subject: [PATCH 184/980] Do not escape redmine reports Redmine comments are plain text and should not be escpated --- timed/redmine/management/commands/redmine_report.py | 2 +- timed/redmine/tests/test_redmine_report.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index f781d2e11..afc1d372c 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -69,7 +69,7 @@ def handle(self, *args, **options): 'last_days': last_days, 'total_hours': total_hours, 'reports': reports - }) + }, using='text') issue.custom_fields = [{ 'id': settings.REDMINE_SPENTHOURS_FIELD, 'value': total_hours diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 952ecba35..648e2a23a 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -21,7 +21,7 @@ def test_redmine_report(db, freezer, mocker): redmine_class.return_value = redmine_instance freezer.move_to('2017-07-28') - report = ReportFactory.create() + report = ReportFactory.create(comment='ADSY <=> Other') report_hours = report.duration.total_seconds() / 3600 RedmineProject.objects.create(project=report.task.project, issue_id=1000) # report not attached to redmine From b6bf3c482781b42dc620edb01fb6c3d740dbfc17 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 4 Sep 2017 16:54:55 +0200 Subject: [PATCH 185/980] Total hours needs to be zero when there are no reports This for example avoids a 500 error when trying to retrieve reports of a single day where nothing has been booked yet. --- timed/tracking/serializers.py | 5 ++++- timed/tracking/tests/test_report.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 7a0e3854a..981e2ffff 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -1,4 +1,5 @@ """Serializers for the tracking app.""" +from datetime import timedelta from django.contrib.auth import get_user_model from django.db.models import Sum @@ -173,7 +174,9 @@ def get_root_meta(self, resource, many): view = self.context['view'] queryset = view.filter_queryset(view.get_queryset()) data = queryset.aggregate(total_hours=Sum('duration')) - data['total_hours'] = duration_string(data['total_hours']) + data['total_hours'] = duration_string( + data['total_hours'] or timedelta(0) + ) return data return {} diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index d5da167b7..69e546dd8 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -516,3 +516,12 @@ def test_report_export(self, file_type, reports): # bookdict is a dict of tuples(name, content) sheet = book.bookdict.popitem()[1] assert len(sheet) == len(reports) + 1 + + +def test_report_list_no_result(admin_client): + url = reverse('report-list') + res = admin_client.get(url) + + assert res.status_code == HTTP_200_OK + json = res.json() + assert json['meta']['total-hours'] == '00:00:00' From 39b26f58aa50b7192977f999c5fb16a54afce60a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 5 Sep 2017 09:29:32 +0200 Subject: [PATCH 186/980] Allow ordering reports by verified by, review and not billable --- timed/tracking/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 71e841efb..71275451a 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -96,6 +96,9 @@ class ReportViewSet(ModelViewSet): 'task__name', 'user__username', 'comment', + 'verified_by__username', + 'review', + 'not_billable' ) @list_route() From bf7b436bcb9622fab8a4614cf7a1442a69f4f350 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 5 Sep 2017 10:49:14 +0200 Subject: [PATCH 187/980] Revert "Add spent hours onto project end point" --- timed/projects/serializers.py | 4 +--- timed/projects/tests/test_project.py | 26 ++++++++++++-------------- timed/projects/views.py | 3 --- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 4c91b9ab9..581856dad 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -1,7 +1,7 @@ """Serializers for the projects app.""" from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import DurationField, ModelSerializer +from rest_framework_json_api.serializers import ModelSerializer from timed.projects import models @@ -35,7 +35,6 @@ class ProjectSerializer(ModelSerializer): billing_type = ResourceRelatedField( queryset=models.BillingType.objects.all() ) - spent_hours = DurationField(read_only=True) included_serializers = { 'customer': 'timed.projects.serializers.CustomerSerializer', @@ -50,7 +49,6 @@ class Meta: 'name', 'comment', 'estimated_hours', - 'spent_hours', 'archived', 'customer', 'billing_type' diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 991fb7b4a..aa08ffe17 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,13 +1,11 @@ """Tests for the projects endpoint.""" -from datetime import timedelta from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) from timed.jsonapi_test_case import JSONAPITestCase -from timed.projects.factories import ProjectFactory, TaskFactory -from timed.tracking.factories import ReportFactory +from timed.projects.factories import ProjectFactory class ProjectTests(JSONAPITestCase): @@ -20,7 +18,7 @@ def setUp(self): """Set the environment for the tests up.""" super().setUp() - self.project = ProjectFactory.create() + self.projects = ProjectFactory.create_batch(10) ProjectFactory.create_batch( 10, @@ -29,9 +27,6 @@ def setUp(self): def test_project_list(self): """Should respond with a list of projects.""" - task = TaskFactory.create(project=self.project) - ReportFactory.create_batch(10, task=task, duration=timedelta(hours=1)) - url = reverse('project-list') noauth_res = self.noauth_client.get(url) @@ -42,15 +37,14 @@ def test_project_list(self): result = self.result(res) - assert len(result['data']) == 1 - assert result['data'][0]['attributes']['spent-hours'] == ( - '10:00:00' - ) + assert len(result['data']) == len(self.projects) def test_project_detail(self): """Should respond with a single project.""" + project = self.projects[0] + url = reverse('project-detail', args=[ - self.project.id + project.id ]) noauth_res = self.noauth_client.get(url) @@ -71,8 +65,10 @@ def test_project_create(self): def test_project_update(self): """Should not be able to update an existing project.""" + project = self.projects[0] + url = reverse('project-detail', args=[ - self.project.id + project.id ]) noauth_res = self.noauth_client.patch(url) @@ -83,8 +79,10 @@ def test_project_update(self): def test_project_delete(self): """Should not be able to delete a project.""" + project = self.projects[0] + url = reverse('project-detail', args=[ - self.project.id + project.id ]) noauth_res = self.noauth_client.delete(url) diff --git a/timed/projects/views.py b/timed/projects/views.py index ab60501ef..313ef0b2c 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,6 +1,5 @@ """Viewsets for the projects app.""" -from django.db.models import Sum from rest_framework.viewsets import ReadOnlyModelViewSet from timed.projects import filters, models, serializers @@ -47,8 +46,6 @@ def get_queryset(self): """ return models.Project.objects.select_related( 'customer' - ).annotate( - spent_hours=Sum('customer__projects__tasks__reports__duration') ) From 83976b8b8be45cd3e49f4cfa65d79dda29b3708f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 5 Sep 2017 13:57:46 +0200 Subject: [PATCH 188/980] Update to version 0.3.0 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index b2b0f04cb..45cf9b992 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -__version__ = '0.1.1' +__version__ = '0.3.0' From d32cf9063d7aa23e8fa47723dfaa7457febc3ef1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Sep 2017 11:05:59 +0200 Subject: [PATCH 189/980] Convert estimated_hours to estimated_time timespan --- timed/projects/factories.py | 4 +- .../migrations/0004_auto_20170906_1045.py | 50 +++++++++++++++++++ timed/projects/models.py | 4 +- timed/projects/serializers.py | 4 +- 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 timed/projects/migrations/0004_auto_20170906_1045.py diff --git a/timed/projects/factories.py b/timed/projects/factories.py index e94e702a0..5be040f7d 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -32,7 +32,7 @@ class ProjectFactory(DjangoModelFactory): """Project factory.""" name = Faker('catch_phrase') - estimated_hours = Faker('random_int', min=0, max=2000) + estimated_time = Faker('time_delta') archived = False comment = Faker('sentence') customer = SubFactory('timed.projects.factories.CustomerFactory') @@ -47,7 +47,7 @@ class TaskFactory(DjangoModelFactory): """Task factory.""" name = Faker('company_suffix') - estimated_hours = Faker('random_int', min=0, max=2000) + estimated_time = Faker('time_delta') archived = False project = SubFactory('timed.projects.factories.ProjectFactory') diff --git a/timed/projects/migrations/0004_auto_20170906_1045.py b/timed/projects/migrations/0004_auto_20170906_1045.py new file mode 100644 index 000000000..a0b8cead9 --- /dev/null +++ b/timed/projects/migrations/0004_auto_20170906_1045.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-06 08:45 +from __future__ import unicode_literals + +from datetime import timedelta + +from django.db import migrations, models + + +def migrate_estimated_hours(apps, schema_editor): + Project = apps.get_model('projects', 'Project') + projects = Project.objects.filter(estimated_hours__isnull=False) + for project in projects: + project.estimated_time = timedelta(hours=project.estimated_hours) + project.save() + + Task = apps.get_model('projects', 'Task') + tasks = Task.objects.filter(estimated_hours__isnull=False) + for task in tasks: + task.estimated_time = timedelta(hours=task.estimated_hours) + task.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_auto_20170831_1624'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='estimated_time', + field=models.DurationField(blank=True, null=True), + ), + migrations.AddField( + model_name='task', + name='estimated_time', + field=models.DurationField(blank=True, null=True), + ), + migrations.RunPython(migrate_estimated_hours), + migrations.RemoveField( + model_name='project', + name='estimated_hours', + ), + migrations.RemoveField( + model_name='task', + name='estimated_hours', + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index dcb212c9a..dbcd01fa4 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -51,7 +51,7 @@ class Project(models.Model): name = models.CharField(max_length=255) comment = models.TextField(blank=True) archived = models.BooleanField(default=False) - estimated_hours = models.PositiveIntegerField(blank=True, null=True) + estimated_time = models.DurationField(blank=True, null=True) customer = models.ForeignKey('projects.Customer', related_name='projects') billing_type = models.ForeignKey(BillingType, on_delete=models.SET_NULL, @@ -83,7 +83,7 @@ class Task(models.Model): """ name = models.CharField(max_length=255) - estimated_hours = models.PositiveIntegerField(blank=True, null=True) + estimated_time = models.DurationField(blank=True, null=True) archived = models.BooleanField(default=False) project = models.ForeignKey('projects.Project', related_name='tasks') diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 581856dad..01eee9740 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -48,7 +48,7 @@ class Meta: fields = [ 'name', 'comment', - 'estimated_hours', + 'estimated_time', 'archived', 'customer', 'billing_type' @@ -71,7 +71,7 @@ class Meta: model = models.Task fields = [ 'name', - 'estimated_hours', + 'estimated_time', 'archived', 'project', ] From 68a886f9f4c5904dc8edadde5242349ebb6e3107 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Sep 2017 11:36:14 +0200 Subject: [PATCH 190/980] Rename total-hours to total-time for consistency --- timed/tracking/serializers.py | 6 +++--- timed/tracking/tests/test_report.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 981e2ffff..5fef676aa 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -173,9 +173,9 @@ def get_root_meta(self, resource, many): if many: view = self.context['view'] queryset = view.filter_queryset(view.get_queryset()) - data = queryset.aggregate(total_hours=Sum('duration')) - data['total_hours'] = duration_string( - data['total_hours'] or timedelta(0) + data = queryset.aggregate(total_time=Sum('duration')) + data['total_time'] = duration_string( + data['total_time'] or timedelta(0) ) return data return {} diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 69e546dd8..0718656bf 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -62,7 +62,7 @@ def test_report_list(self): assert len(result['data']) == 1 assert result['data'][0]['id'] == str(self.reports[0].id) - assert result['meta']['total-hours'] == '01:00:00' + assert result['meta']['total-time'] == '01:00:00' def test_report_list_filter_reviewer(self): report = self.reports[0] @@ -92,7 +92,7 @@ def test_report_list_verify(self): assert res.status_code == HTTP_200_OK result = self.result(res) assert len(result['data']) == 10 - assert result['meta']['total-hours'] == '10:00:00' + assert result['meta']['total-time'] == '10:00:00' def test_report_list_verify_non_admin(self): """Non admin resp. non staff user may not verify reports.""" @@ -524,4 +524,4 @@ def test_report_list_no_result(admin_client): assert res.status_code == HTTP_200_OK json = res.json() - assert json['meta']['total-hours'] == '00:00:00' + assert json['meta']['total-time'] == '00:00:00' From c417bb799f2753f44acfb4d85b46c0611890ddfa Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Sep 2017 11:36:37 +0200 Subject: [PATCH 191/980] Add spent-time to detail route of Tasks and Project --- timed/conftest.py | 5 +++ timed/projects/serializers.py | 26 ++++++++++++ timed/projects/tests/test_project.py | 61 +++++++++++++++++++++------- timed/projects/tests/test_task.py | 31 ++++++++++++++ 4 files changed, 108 insertions(+), 15 deletions(-) diff --git a/timed/conftest.py b/timed/conftest.py index 2ab71ed45..e54c8ae95 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -4,6 +4,11 @@ from timed.jsonapi_test_case import JSONAPIClient +@pytest.fixture +def client(db): + return JSONAPIClient() + + @pytest.fixture def auth_client(db): """Return instance of a JSONAPIClient that is logged in as test user.""" diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 01eee9740..3401a7f0c 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -1,9 +1,13 @@ """Serializers for the projects app.""" +from datetime import timedelta +from django.db.models import Sum +from django.utils.duration import duration_string from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ModelSerializer from timed.projects import models +from timed.tracking.models import Report class CustomerSerializer(ModelSerializer): @@ -41,6 +45,17 @@ class ProjectSerializer(ModelSerializer): 'billing_type': 'timed.projects.serializers.BillingTypeSerializer' } + def get_root_meta(self, resource, many): + if not many: + queryset = Report.objects.filter(task__project=self.instance) + data = queryset.aggregate(spent_time=Sum('duration')) + data['spent_time'] = duration_string( + data['spent_time'] or timedelta(0) + ) + return data + + return {} + class Meta: """Meta information for the project serializer.""" @@ -65,6 +80,17 @@ class TaskSerializer(ModelSerializer): 'project': 'timed.projects.serializers.ProjectSerializer' } + def get_root_meta(self, resource, many): + if not many: + queryset = Report.objects.filter(task=self.instance) + data = queryset.aggregate(spent_time=Sum('duration')) + data['spent_time'] = duration_string( + data['spent_time'] or timedelta(0) + ) + return data + + return {} + class Meta: """Meta information for the task serializer.""" diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index aa08ffe17..b8011f9d9 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,11 +1,13 @@ """Tests for the projects endpoint.""" +from datetime import timedelta from django.core.urlresolvers import reverse from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) from timed.jsonapi_test_case import JSONAPITestCase -from timed.projects.factories import ProjectFactory +from timed.projects.factories import ProjectFactory, TaskFactory +from timed.tracking.factories import ReportFactory class ProjectTests(JSONAPITestCase): @@ -39,20 +41,6 @@ def test_project_list(self): assert len(result['data']) == len(self.projects) - def test_project_detail(self): - """Should respond with a single project.""" - project = self.projects[0] - - url = reverse('project-detail', args=[ - project.id - ]) - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - def test_project_create(self): """Should not be able to create a new project.""" url = reverse('project-list') @@ -90,3 +78,46 @@ def test_project_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + +def test_project_detail_no_auth(client): + project = ProjectFactory.create() + + url = reverse('project-detail', args=[ + project.id + ]) + + res = client.get(url) + assert res.status_code == HTTP_401_UNAUTHORIZED + + +def test_project_detail_no_reports(auth_client): + project = ProjectFactory.create() + + url = reverse('project-detail', args=[ + project.id + ]) + + res = auth_client.get(url) + + assert res.status_code == HTTP_200_OK + json = res.json() + + assert json['meta']['spent-time'] == '00:00:00' + + +def test_project_detail_with_reports(auth_client): + project = ProjectFactory.create() + task = TaskFactory.create(project=project) + ReportFactory.create_batch(10, task=task, duration=timedelta(hours=1)) + + url = reverse('project-detail', args=[ + project.id + ]) + + res = auth_client.get(url) + + assert res.status_code == HTTP_200_OK + json = res.json() + + assert json['meta']['spent-time'] == '10:00:00' diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 16fda7b79..4dcba7706 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -128,3 +128,34 @@ def test_task_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + + +def test_task_detail_no_reports(auth_client): + task = TaskFactory.create() + + url = reverse('task-detail', args=[ + task.id + ]) + + res = auth_client.get(url) + + assert res.status_code == HTTP_200_OK + + json = res.json() + assert json['meta']['spent-time'] == '00:00:00' + + +def test_task_detail_with_reports(auth_client): + task = TaskFactory.create() + ReportFactory.create_batch(5, task=task, duration=timedelta(minutes=30)) + + url = reverse('task-detail', args=[ + task.id + ]) + + res = auth_client.get(url) + + assert res.status_code == HTTP_200_OK + + json = res.json() + assert json['meta']['spent-time'] == '02:30:00' From deedc2b4c55f0ed761307c6e2155187703d9ecf6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Sep 2017 15:14:26 +0200 Subject: [PATCH 192/980] Add management command to notify on changed employments --- .../migrations/0005_auto_20170906_1259.py | 27 ++++++++ timed/employment/models.py | 3 + .../commands/notify_changed_employments.py | 62 +++++++++++++++++++ .../mail/notify_changed_employments.txt | 3 + .../tests/test_notify_changed_employments.py | 32 ++++++++++ 5 files changed, 127 insertions(+) create mode 100644 timed/employment/migrations/0005_auto_20170906_1259.py create mode 100644 timed/reports/management/commands/notify_changed_employments.py create mode 100644 timed/reports/templates/mail/notify_changed_employments.txt create mode 100644 timed/reports/tests/test_notify_changed_employments.py diff --git a/timed/employment/migrations/0005_auto_20170906_1259.py b/timed/employment/migrations/0005_auto_20170906_1259.py new file mode 100644 index 000000000..83bc0a60d --- /dev/null +++ b/timed/employment/migrations/0005_auto_20170906_1259.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-06 10:59 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0004_auto_20170904_1510'), + ] + + operations = [ + migrations.AddField( + model_name='employment', + name='added', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='employment', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 4556dbd79..e3561484f 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -274,6 +274,9 @@ class Employment(models.Model): end_date = models.DateField(blank=True, null=True) objects = EmploymentManager() + added = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + def __str__(self): """Represent the model as a string. diff --git a/timed/reports/management/commands/notify_changed_employments.py b/timed/reports/management/commands/notify_changed_employments.py new file mode 100644 index 000000000..b6551f450 --- /dev/null +++ b/timed/reports/management/commands/notify_changed_employments.py @@ -0,0 +1,62 @@ +from datetime import timedelta + +from django.conf import settings +from django.core.mail import EmailMessage +from django.core.management.base import BaseCommand +from django.template.loader import render_to_string +from django.utils import timezone + +from timed.employment.models import Employment + + +class Command(BaseCommand): + """ + Notify given email address on changed employments. + + Notifications will be sent when there are employments + which changed in given last days. + """ + + help = 'Send notification on given email address on changed employments.' + + def add_arguments(self, parser): + parser.add_argument( + '--email', + type=str, + dest='email', + help='Email address notification is sent to.' + ) + parser.add_argument( + '--last-days', + default=7, + type=int, + dest='last_days', + help='Time frame of last days employment changed.' + ) + + def handle(self, *args, **options): + email = options['email'] + last_days = options['last_days'] + + # today is excluded + end = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = end - timedelta(days=last_days) + + employments = Employment.objects.filter(updated__range=[start, end]) + if employments.exists(): + from_email = settings.DEFAULT_FROM_EMAIL + subject = '[Timed] Employments changed in last {0} days'.format( + last_days + ) + body = render_to_string( + 'mail/notify_changed_employments.txt', { + 'employments': employments + }, using='text' + ) + message = EmailMessage( + subject=subject, + body=body, + from_email=from_email, + to=[email], + ) + message.send() diff --git a/timed/reports/templates/mail/notify_changed_employments.txt b/timed/reports/templates/mail/notify_changed_employments.txt new file mode 100644 index 000000000..c0ff000b2 --- /dev/null +++ b/timed/reports/templates/mail/notify_changed_employments.txt @@ -0,0 +1,3 @@ +Changed employments: +{% for employment in employments %} +{{employment.start_date}} - {{employment.end_date|ljust:10}} {{employment.percentage|stringformat:"s"|add:'%'|rjust:4}} {{employment.user.get_full_name}}{% endfor %} diff --git a/timed/reports/tests/test_notify_changed_employments.py b/timed/reports/tests/test_notify_changed_employments.py new file mode 100644 index 000000000..3b97ed7b5 --- /dev/null +++ b/timed/reports/tests/test_notify_changed_employments.py @@ -0,0 +1,32 @@ +from datetime import date + +import pytest +from django.core.management import call_command + +from timed.employment.factories import EmploymentFactory + + +@pytest.mark.freeze_time +def test_notify_changed_employments(db, mailoutbox, freezer): + email = 'test@example.net' + + # employments changed too far in the past + freezer.move_to('2017-08-27') + EmploymentFactory.create_batch(2) + + # employments which should show up in report + freezer.move_to('2017-09-03') + finished = EmploymentFactory.create(end_date=date(2017, 10, 10), + percentage=80) + new = EmploymentFactory.create(percentage=100) + + freezer.move_to('2017-09-04') + call_command('notify_changed_employments', email=email) + + # checks + assert len(mailoutbox) == 1 + mail = mailoutbox[0] + assert mail.to == [email] + print(mail.body) + assert '80% {0}'.format(finished.user.get_full_name()) in mail.body + assert 'None 100% {0}'.format(new.user.get_full_name()) in mail.body From e5ed1522657e95b3550339c447decfec50c55c7f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Sep 2017 16:34:17 +0200 Subject: [PATCH 193/980] Display all duration as hours in Django admin Default duration string is not human readable therefore replacing it with float hours. --- .coveragerc | 1 + timed/employment/admin.py | 11 ++++++++++ .../migrations/0006_auto_20170906_1635.py | 21 +++++++++++++++++++ timed/employment/models.py | 2 +- timed/forms.py | 19 +++++++++++++++++ timed/projects/admin.py | 14 +++++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 timed/employment/migrations/0006_auto_20170906_1635.py create mode 100644 timed/forms.py diff --git a/.coveragerc b/.coveragerc index e0a3820cf..4e6fce89b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -18,6 +18,7 @@ omit= manage.py timed/settings_*.py timed/wsgi.py + timed/forms.py setup.py show_missing = True diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 7b4f30c9b..01b4a632d 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from timed.employment import models +from timed.forms import DurationInHoursField # do not allow deletion of objects site wide # objects need to be deactivated resp. archived @@ -34,6 +35,10 @@ class SuperviseeInline(admin.TabularInline): class EmploymentForm(forms.ModelForm): """Custom form for the employment admin.""" + worktime_per_day = DurationInHoursField( + label=_('Worktime per day in hours') + ) + def clean(self): """Validate the employment as a whole. @@ -97,8 +102,14 @@ class EmploymentInline(admin.TabularInline): extra = 0 +class OvertimeCreditForm(forms.ModelForm): + model = models.OvertimeCredit + duration = DurationInHoursField(label=_('Duration in hours')) + + class OvertimeCreditInline(admin.TabularInline): model = models.OvertimeCredit + form = OvertimeCreditForm extra = 0 diff --git a/timed/employment/migrations/0006_auto_20170906_1635.py b/timed/employment/migrations/0006_auto_20170906_1635.py new file mode 100644 index 000000000..acf6ee825 --- /dev/null +++ b/timed/employment/migrations/0006_auto_20170906_1635.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-06 14:35 +from __future__ import unicode_literals + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0005_auto_20170906_1259'), + ] + + operations = [ + migrations.AlterField( + model_name='overtimecredit', + name='duration', + field=models.DurationField(default=datetime.timedelta(0)), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index e3561484f..def07e53e 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -112,7 +112,7 @@ class OvertimeCredit(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='overtime_credits') date = models.DateField() - duration = models.DurationField(blank=True, null=True) + duration = models.DurationField(default=timedelta(0)) class UserAbsenceTypeManager(models.Manager): diff --git a/timed/forms.py b/timed/forms.py new file mode 100644 index 000000000..db168726b --- /dev/null +++ b/timed/forms.py @@ -0,0 +1,19 @@ +from datetime import timedelta + +from django import forms + + +class DurationInHoursField(forms.fields.FloatField): + """Field representing duration as float hours.""" + + def prepare_value(self, value): + if isinstance(value, timedelta): + return value.total_seconds() / 3600 + return value + + def to_python(self, value): + value = super().to_python(value) + if value is None: + return value + + return timedelta(seconds=value * 3600) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 4d36492be..4367e8202 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -5,6 +5,7 @@ from django.forms.models import BaseInlineFormSet from django.utils.translation import ugettext_lazy as _ +from timed.forms import DurationInHoursField from timed.projects import models @@ -33,6 +34,10 @@ class TaskForm(forms.ModelForm): """ model = models.Task + estimated_time = DurationInHoursField( + label=_('Estimated time in hours'), + required=False, + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -75,10 +80,19 @@ class ReviewerInline(admin.TabularInline): verbose_name_plural = _('Reviewers') +class ProjectForm(forms.ModelForm): + model = models.Project + estimated_time = DurationInHoursField( + label=_('Estimated time in hours'), + required=False, + ) + + @admin.register(models.Project) class ProjectAdmin(admin.ModelAdmin): """Project admin view.""" + form = ProjectForm list_display = ['name', 'customer'] list_filter = ['customer'] search_fields = ['name', 'customer__name'] From da68ca7b5fe32e94779b026ef8ae82dd6714e891 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 7 Sep 2017 09:42:47 +0200 Subject: [PATCH 194/980] Add reference field to project models This reference field may be used by external systems importing project data into Timed to reference back to its origin. --- timed/projects/factories.py | 2 +- timed/projects/filters.py | 19 +++++- .../migrations/0005_auto_20170907_0938.py | 61 +++++++++++++++++++ timed/projects/models.py | 29 +++++---- timed/projects/serializers.py | 8 ++- 5 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 timed/projects/migrations/0005_auto_20170907_0938.py diff --git a/timed/projects/factories.py b/timed/projects/factories.py index 5be040f7d..51dcca171 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -9,7 +9,7 @@ class CustomerFactory(DjangoModelFactory): """Customer factory.""" - name = Faker('company') + name = Faker('uuid4') email = Faker('company_email') website = Faker('url') comment = Faker('sentence') diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 5ef91b35b..adc702a26 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -16,7 +16,10 @@ class Meta: """Meta information for the customer filter set.""" model = models.Customer - fields = ['archived'] + fields = [ + 'archived', + 'reference' + ] class ProjectFilterSet(FilterSet): @@ -28,7 +31,12 @@ class Meta: """Meta information for the project filter set.""" model = models.Project - fields = ['archived', 'customer', 'billing_type'] + fields = [ + 'archived', + 'customer', + 'billing_type', + 'reference' + ] class MyMostFrequentTaskFilter(Filter): @@ -73,4 +81,9 @@ class Meta: """Meta information for the task filter set.""" model = models.Task - fields = ['archived', 'project', 'my_most_frequent'] + fields = [ + 'archived', + 'project', + 'my_most_frequent', + 'reference' + ] diff --git a/timed/projects/migrations/0005_auto_20170907_0938.py b/timed/projects/migrations/0005_auto_20170907_0938.py new file mode 100644 index 000000000..9395061fd --- /dev/null +++ b/timed/projects/migrations/0005_auto_20170907_0938.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-07 07:38 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_auto_20170906_1045'), + ] + + operations = [ + migrations.AlterModelOptions( + name='billingtype', + options={'ordering': ['name']}, + ), + migrations.RemoveIndex( + model_name='customer', + name='projects_cu_name_e0e97a_idx', + ), + migrations.RemoveIndex( + model_name='task', + name='projects_ta_name_dd9620_idx', + ), + migrations.RemoveIndex( + model_name='project', + name='projects_pr_name_ac60a8_idx', + ), + migrations.AddField( + model_name='billingtype', + name='reference', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='customer', + name='reference', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='project', + name='reference', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='task', + name='reference', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='customer', + name='name', + field=models.CharField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(db_index=True, max_length=255), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index dbcd01fa4..331a4560d 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -11,11 +11,13 @@ class Customer(models.Model): reported on their projects. """ - name = models.CharField(max_length=255) - email = models.EmailField(blank=True) - website = models.URLField(blank=True) - comment = models.TextField(blank=True) - archived = models.BooleanField(default=False) + name = models.CharField(max_length=255, unique=True) + reference = models.CharField(max_length=255, db_index=True, + blank=True, null=True) + email = models.EmailField(blank=True) + website = models.URLField(blank=True) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) def __str__(self): """Represent the model as a string. @@ -28,18 +30,21 @@ def __str__(self): class Meta: """Meta informations for the customer model.""" - indexes = [models.Index(fields=['name', 'archived'])] ordering = ['name'] class BillingType(models.Model): """Billing type defining how a project, resp. reports are being billed.""" - name = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255, unique=True) + reference = models.CharField(max_length=255, blank=True, null=True) def __str__(self): return self.name + class Meta: + ordering = ['name'] + class Project(models.Model): """Project model. @@ -48,7 +53,9 @@ class Project(models.Model): belongs to a customer. """ - name = models.CharField(max_length=255) + name = models.CharField(max_length=255, db_index=True) + reference = models.CharField(max_length=255, db_index=True, + blank=True, null=True) comment = models.TextField(blank=True) archived = models.BooleanField(default=False) estimated_time = models.DurationField(blank=True, null=True) @@ -69,9 +76,6 @@ def __str__(self): return '{0} > {1}'.format(self.customer, self.name) class Meta: - """Meta informations for the project model.""" - - indexes = [models.Index(fields=['name', 'archived'])] ordering = ['name'] @@ -83,6 +87,8 @@ class Task(models.Model): """ name = models.CharField(max_length=255) + reference = models.CharField(max_length=255, db_index=True, + blank=True, null=True) estimated_time = models.DurationField(blank=True, null=True) archived = models.BooleanField(default=False) project = models.ForeignKey('projects.Project', @@ -99,7 +105,6 @@ def __str__(self): class Meta: """Meta informations for the task model.""" - indexes = [models.Index(fields=['name', 'archived'])] ordering = ['name'] diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 3401a7f0c..73a84aa05 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -19,6 +19,7 @@ class Meta: model = models.Customer fields = [ 'name', + 'reference', 'email', 'website', 'comment', @@ -29,7 +30,10 @@ class Meta: class BillingTypeSerializer(ModelSerializer): class Meta: model = models.BillingType - fields = ['name'] + fields = [ + 'name', + 'reference' + ] class ProjectSerializer(ModelSerializer): @@ -62,6 +66,7 @@ class Meta: model = models.Project fields = [ 'name', + 'reference', 'comment', 'estimated_time', 'archived', @@ -97,6 +102,7 @@ class Meta: model = models.Task fields = [ 'name', + 'reference', 'estimated_time', 'archived', 'project', From 8ddd9e1d62ab809075050ac448f5e8615d222e78 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 11 Sep 2017 10:14:17 +0200 Subject: [PATCH 195/980] Remove absence_credits reverse related manager on absence types This reverse related manager led to too many includes when retrieving absence credits of user absence types. --- timed/employment/admin.py | 2 +- .../migrations/0007_auto_20170911_0959.py | 21 ++++++++++++ timed/employment/models.py | 3 +- timed/employment/tests/test_user.py | 34 ++++++++++++------- 4 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 timed/employment/migrations/0007_auto_20170911_0959.py diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 01b4a632d..545939562 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -199,5 +199,5 @@ def has_delete_permission(self, request, obj=None): return ( obj and not obj.absences.exists() and - not obj.absence_credits.exists() + not obj.absencecredit_set.exists() ) diff --git a/timed/employment/migrations/0007_auto_20170911_0959.py b/timed/employment/migrations/0007_auto_20170911_0959.py new file mode 100644 index 000000000..e6b9d4af7 --- /dev/null +++ b/timed/employment/migrations/0007_auto_20170911_0959.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-09-11 07:59 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0006_auto_20170906_1635'), + ] + + operations = [ + migrations.AlterField( + model_name='absencecredit', + name='absence_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.AbsenceType'), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index def07e53e..26cda2afb 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -96,8 +96,7 @@ class AbsenceCredit(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='absence_credits') comment = models.CharField(max_length=255, blank=True) - absence_type = models.ForeignKey(AbsenceType, - related_name='absence_credits') + absence_type = models.ForeignKey(AbsenceType) date = models.DateField() days = models.IntegerField(default=0) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 3f3bb8f5f..75c15b332 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -161,10 +161,13 @@ def test_user_without_employment(self): def test_user_absence_types(self): absence_type = AbsenceTypeFactory.create() - credit = AbsenceCreditFactory.create(date=date.today(), - user=self.user, - days=5, - absence_type=absence_type) + absence_credit = AbsenceCreditFactory.create(date=date.today(), + user=self.user, days=5, + absence_type=absence_type) + + # credit on different user, may not show up on included + AbsenceCreditFactory.create(date=date.today(), + absence_type=absence_type) AbsenceFactory.create(date=date.today(), user=self.user, @@ -178,7 +181,9 @@ def test_user_absence_types(self): self.user.id ]) - res = self.client.get(url, {'include': 'user_absence_types'}) + res = self.client.get(url, { + 'include': 'user_absence_types,user_absence_types.absence_credits' + }) assert res.status_code == HTTP_200_OK @@ -188,17 +193,20 @@ def test_user_absence_types(self): inc = result['included'] assert len(rel['user-absence-types']['data']) == 1 - assert len(inc) == 1 + assert len(inc) == 2 + absence_type_inc = inc[0] + assert absence_type_inc['id'] == str(absence_credit.id) + + absence_credit_inc = inc[1] assert ( - inc[0]['id'] == - '{0}-{1}'.format(self.user.id, credit.absence_type.id) + absence_credit_inc['id'] == + '{0}-{1}'.format(self.user.id, absence_credit.absence_type.id) ) - - assert inc[0]['attributes']['credit'] == 5 - assert inc[0]['attributes']['balance'] == 3 - assert inc[0]['attributes']['used-days'] == 2 - assert inc[0]['attributes']['used-duration'] is None + assert absence_credit_inc['attributes']['credit'] == 5 + assert absence_credit_inc['attributes']['balance'] == 3 + assert absence_credit_inc['attributes']['used-days'] == 2 + assert absence_credit_inc['attributes']['used-duration'] is None def test_user_absence_types_fill_worktime(self): absence_type = AbsenceTypeFactory.create(fill_worktime=True) From 46b8c61e5df876f1a0e19a76ff6f08c6778cc339 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 11 Sep 2017 15:38:59 +0200 Subject: [PATCH 196/980] Update to version 0.4.0 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 45cf9b992..5e3252336 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -__version__ = '0.3.0' +__version__ = '0.4.0' From 595bd5901dc308df1bfd3b1713c0707a82f5a539 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 10:05:12 +0200 Subject: [PATCH 197/980] No coverage on fail When tests fails coverage is inaccurate so disabling it to receive quicker results on failures. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2fc4050b1..45708017c 100644 --- a/Makefile +++ b/Makefile @@ -32,4 +32,4 @@ test: ## Test the project ./manage.py migrate --noinput ./manage.py makemigrations --check --dry-run --noinput @flake8 - @pytest --cov --create-db + @pytest --no-cov-on-fail --cov --create-db From 689b456bfa234a35986ce7ae784cf13218a130ee Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 14:30:27 +0200 Subject: [PATCH 198/980] Add docker image with uwsgi --- .dockerignore | 4 ++++ Dockerfile | 24 ++++++++++++++++++++++++ docker-compose.yml | 19 ++++++++++--------- uwsgi.ini | 8 ++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 uwsgi.ini diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..6d4a87b10 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +docker-compose.yml +docker +*.swp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..8a91333c3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.5.4 + +RUN apt-get update && apt-get install -y \ + libldap2-dev \ + libsasl2-dev \ + python-pip + +RUN pip install uwsgi==2.0.15 + +RUN mkdir -p /var/www/static + +ENV DJANGO_SETTINGS_MODULE timed.settings +ENV STATIC_ROOT /var/www/static +ENV UWSGI_INI /app/docker/backend/uwsgi.ini + +COPY . /app +WORKDIR /app + +RUN make install + +RUN ./manage.py collectstatic --noinput + +EXPOSE 80 +CMD ["uwsgi"] diff --git a/docker-compose.yml b/docker-compose.yml index edf28f9e3..4c6f0dd71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,14 +7,15 @@ services: environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed - ucs: - build: ./docker/ucs - hostname: timed-ucs - volumes: - - ./docker/ucs/timed-ucs.profile:/var/cache/univention-system-setup/profile - - ./docker/ucs/scripts:/usr/ucs/scripts + backend: + build: . ports: - - '389:389' - - '8080:80' + - '8000:80' + volumes: + - .:/app + depends_on: + - db environment: - - rootpwd=univention + - DATABASE_URL=psql://timed:timed@db:5432/timed + - ENV=docker + - STATIC_ROOT=/var/www/static diff --git a/uwsgi.ini b/uwsgi.ini new file mode 100644 index 000000000..2bcefda77 --- /dev/null +++ b/uwsgi.ini @@ -0,0 +1,8 @@ +[uwsgi] +http = 0.0.0.0:80 +wsgi-file = /app/timed/wsgi.py +max-requests = 2000 +harakiri = 5 +processes = 4 +master = True +static-map = /static/=/var/www/static From e91ba902961033fa13fe36e3a7912ce46bd77eb6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 14:38:00 +0200 Subject: [PATCH 199/980] Make ldap configurable --- timed/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index 51b91901a..3f4351b25 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -179,7 +179,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'email': 'mail' }) -LDAP_BASE = 'dc=adsy-ext,dc=becs,dc=adfinis-sygroup,dc=ch' +LDAP_BASE = env.str('DJANGO_LDAP_BASE', default='dc=example,dc=com') AUTH_LDAP_SERVER_URI = env.str( 'DJANGO_AUTH_LDAP_SERVER_URI', default('ldap://localhost:389') From f055891d3db0c1f6929f13353fbd4bc3a570847d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 14:42:50 +0200 Subject: [PATCH 200/980] do not install recommends deps in docker --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8a91333c3..b52f6d37b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.5.4 -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ python-pip From eb3f3434509d0733c5d766f8001dd41d16e1d97f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 14:44:20 +0200 Subject: [PATCH 201/980] Clean up apt lists in docker container --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b52f6d37b..6e991cd22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,8 @@ FROM python:3.5.4 RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ - python-pip + python-pip \ +&& rm -rf /var/lib/apt/lists/* RUN pip install uwsgi==2.0.15 From 526e8c8631da7a3bf992b463da33fcb0c261c072 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 15:18:15 +0200 Subject: [PATCH 202/980] uwsgi.ini moved to root --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6e991cd22..c7a9d0e1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN mkdir -p /var/www/static ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static -ENV UWSGI_INI /app/docker/backend/uwsgi.ini +ENV UWSGI_INI /app/uwsgi.ini COPY . /app WORKDIR /app From 41903eeb0b0f57b4674c8f9e1862eef4ac24ef8a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 15:18:33 +0200 Subject: [PATCH 203/980] Integrate ucs container again --- Makefile | 8 +++----- docker-compose.yml | 12 +++++++++++- docker/ucs/Dockerfile | 5 ----- docker/ucs/scripts/create-new-user.sh | 4 ++-- docker/ucs/scripts/init.sh | 4 ++-- docker/ucs/timed-ucs.profile | 6 +++--- 6 files changed, 21 insertions(+), 18 deletions(-) delete mode 100644 docker/ucs/Dockerfile diff --git a/Makefile b/Makefile index 45708017c..b0efdba6e 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,6 @@ .PHONY: help install install-dev setup-ldap create-ldap-user start docs test .DEFAULT_GOAL := help -UCS_CONTAINER_ID=$(shell docker-compose ps -q ucs) - help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -15,11 +13,11 @@ install-dev: ## Install development environment @pip install --upgrade -r dev_requirements.txt -e . setup-ldap: ## Setup the LDAP container - docker exec -it $(UCS_CONTAINER_ID) /usr/lib/univention-system-setup/scripts/setup-join.sh - docker exec -it $(UCS_CONTAINER_ID) /usr/ucs/scripts/init.sh + docker-compose exec ucs /usr/lib/univention-system-setup/scripts/setup-join.sh + docker-compose exec ucs /usr/ucs/scripts/init.sh create-ldap-user: ## Create a new user in the LDAP - docker exec -it $(UCS_CONTAINER_ID) /usr/ucs/scripts/create-new-user.sh + docker-compose exec ucs /usr/ucs/scripts/create-new-user.sh start: ## Start the development server @docker-compose start diff --git a/docker-compose.yml b/docker-compose.yml index 4c6f0dd71..e6b757029 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,21 @@ version: '2' services: db: - image: postgres + image: postgres:9.4 ports: - '5432:5432' environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed + ucs: + image: univention/ucs-master-amd64:4.2-1 + hostname: timed-ucs + expose: + - 80 + - 389 + volumes: + - ./docker/ucs/timed-ucs.profile:/var/cache/univention-system-setup/profile + - ./docker/ucs/scripts:/usr/ucs/scripts backend: build: . ports: @@ -15,6 +24,7 @@ services: - .:/app depends_on: - db + - ucs environment: - DATABASE_URL=psql://timed:timed@db:5432/timed - ENV=docker diff --git a/docker/ucs/Dockerfile b/docker/ucs/Dockerfile deleted file mode 100644 index 0057248c7..000000000 --- a/docker/ucs/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM univention/ucs-master-amd64 - -EXPOSE 80 389 636 - -ENTRYPOINT [ "/sbin/init" ] diff --git a/docker/ucs/scripts/create-new-user.sh b/docker/ucs/scripts/create-new-user.sh index 56ad37ae2..0f8bc0618 100755 --- a/docker/ucs/scripts/create-new-user.sh +++ b/docker/ucs/scripts/create-new-user.sh @@ -7,7 +7,7 @@ read lastname firstname_l="$(echo $firstname | tr '[:upper:]' '[:lower:]')" lastname_l="$(echo $lastname | tr '[:upper:]' '[:lower:]')" -email="$firstname_l.$lastname_l@adfinis-sygroup.ch" +email="$firstname_l.$lastname_l@example.com" username="$firstname_l$(echo $lastname_l | head -c 1)" password="123qweasd" @@ -20,7 +20,7 @@ udm users/user create \ --set description="$firstname $lastname" \ --set e-mail="$email" \ --set shell="/bin/bash" \ - --set primaryGroup="cn=adsy-user,cn=groups,$(ucr get ldap/base)" + --set primaryGroup="cn=example-group,cn=groups,$(ucr get ldap/base)" echo "" echo "Name: $firstname $lastname" diff --git a/docker/ucs/scripts/init.sh b/docker/ucs/scripts/init.sh index 64101c921..f6670070b 100755 --- a/docker/ucs/scripts/init.sh +++ b/docker/ucs/scripts/init.sh @@ -1,6 +1,6 @@ #!/bin/bash udm groups/group create \ - --set name="adsy-user" \ - --set description="adsy user" \ + --set name="example-group" \ + --set description="example group" \ --position="cn=groups,$(ucr get ldap/base)" diff --git a/docker/ucs/timed-ucs.profile b/docker/ucs/timed-ucs.profile index 3f2948773..33894d04e 100644 --- a/docker/ucs/timed-ucs.profile +++ b/docker/ucs/timed-ucs.profile @@ -1,7 +1,7 @@ hostname="timed-ucs" -domainname="adsy-ext.becs.adfinis-sygroup.ch" -windows/domain="ADSY-EXT" -ldap/base="dc=adsy-ext,dc=becs,dc=adfinis-sygroup,dc=ch" +domainname="example.com" +windows/domain="EXAMPLE" +ldap/base="dc=example,dc=com" root_password="univention" server/role="domaincontroller_master" From ad072c1129f30cdda3e4fd88837e10f3a8d18b67 Mon Sep 17 00:00:00 2001 From: Lucas Bickel Date: Wed, 13 Sep 2017 15:35:32 +0200 Subject: [PATCH 204/980] Set ENV env during build --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index c7a9d0e1d..2225b98e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN mkdir -p /var/www/static ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static ENV UWSGI_INI /app/uwsgi.ini +ENV ENV docker COPY . /app WORKDIR /app From 525953a68a64e7950587de8392db4287dda594b8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Sep 2017 17:26:31 +0200 Subject: [PATCH 205/980] Only set env as docker during collectstatic --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2225b98e2..9f79e3b1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,14 +13,13 @@ RUN mkdir -p /var/www/static ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static ENV UWSGI_INI /app/uwsgi.ini -ENV ENV docker COPY . /app WORKDIR /app RUN make install -RUN ./manage.py collectstatic --noinput +RUN ENV=docker ./manage.py collectstatic --noinput EXPOSE 80 CMD ["uwsgi"] From 2a82eff825aa9f8e328c84d202ac495c32ba5431 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 15 Sep 2017 10:50:25 +0200 Subject: [PATCH 206/980] Make ldap authentication optional * ldap is quite a specific use case hence needs to be disabled by default * Remove ucs ldap container as every ldap is quite specific. ldap auth only reads; for testing purposes it is not critical to configure ldap in local .env * unit test currently do not test ldap. For future instead of using a ldap container, it might be better to use mockldap library --- Makefile | 7 ---- docker-compose.yml | 10 ------ docker/ucs/README.md | 12 ------- docker/ucs/scripts/create-new-user.sh | 32 ------------------ docker/ucs/scripts/init.sh | 6 ---- docker/ucs/timed-ucs.profile | 9 ----- timed/settings.py | 48 +++++++++++---------------- 7 files changed, 20 insertions(+), 104 deletions(-) delete mode 100644 docker/ucs/README.md delete mode 100755 docker/ucs/scripts/create-new-user.sh delete mode 100755 docker/ucs/scripts/init.sh delete mode 100644 docker/ucs/timed-ucs.profile diff --git a/Makefile b/Makefile index b0efdba6e..20c33b14a 100644 --- a/Makefile +++ b/Makefile @@ -12,13 +12,6 @@ install-dev: ## Install development environment @pip install --upgrade pip @pip install --upgrade -r dev_requirements.txt -e . -setup-ldap: ## Setup the LDAP container - docker-compose exec ucs /usr/lib/univention-system-setup/scripts/setup-join.sh - docker-compose exec ucs /usr/ucs/scripts/init.sh - -create-ldap-user: ## Create a new user in the LDAP - docker-compose exec ucs /usr/ucs/scripts/create-new-user.sh - start: ## Start the development server @docker-compose start @python manage.py runserver diff --git a/docker-compose.yml b/docker-compose.yml index e6b757029..4642558bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,15 +7,6 @@ services: environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed - ucs: - image: univention/ucs-master-amd64:4.2-1 - hostname: timed-ucs - expose: - - 80 - - 389 - volumes: - - ./docker/ucs/timed-ucs.profile:/var/cache/univention-system-setup/profile - - ./docker/ucs/scripts:/usr/ucs/scripts backend: build: . ports: @@ -24,7 +15,6 @@ services: - .:/app depends_on: - db - - ucs environment: - DATABASE_URL=psql://timed:timed@db:5432/timed - ENV=docker diff --git a/docker/ucs/README.md b/docker/ucs/README.md deleted file mode 100644 index a8b4805bf..000000000 --- a/docker/ucs/README.md +++ /dev/null @@ -1,12 +0,0 @@ -After running the ucs container, launch this command: - -```sh -$ docker exec backend_ucs_1 /usr/lib/univention-system-setup/scripts/setup-join.sh -``` - -To add some dummy data for testing, use: - -```sh -$ docker exec backend_ucs_1 /usr/ucs/scripts/fill-dummy-data.sh -``` - diff --git a/docker/ucs/scripts/create-new-user.sh b/docker/ucs/scripts/create-new-user.sh deleted file mode 100755 index 0f8bc0618..000000000 --- a/docker/ucs/scripts/create-new-user.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -echo "First Name:" -read firstname -echo "Last Name:" -read lastname - -firstname_l="$(echo $firstname | tr '[:upper:]' '[:lower:]')" -lastname_l="$(echo $lastname | tr '[:upper:]' '[:lower:]')" -email="$firstname_l.$lastname_l@example.com" -username="$firstname_l$(echo $lastname_l | head -c 1)" -password="123qweasd" - -udm users/user create \ - --position="cn=users,$(ucr get ldap/base)" \ - --set username="$username" \ - --set firstname="$firstname" \ - --set lastname="$lastname" \ - --set password="$password"\ - --set description="$firstname $lastname" \ - --set e-mail="$email" \ - --set shell="/bin/bash" \ - --set primaryGroup="cn=example-group,cn=groups,$(ucr get ldap/base)" - -echo "" -echo "Name: $firstname $lastname" -echo "Username: $username" -echo "Passwort: $password" -echo "Email: $email" -echo "" - -exit diff --git a/docker/ucs/scripts/init.sh b/docker/ucs/scripts/init.sh deleted file mode 100755 index f6670070b..000000000 --- a/docker/ucs/scripts/init.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -udm groups/group create \ - --set name="example-group" \ - --set description="example group" \ - --position="cn=groups,$(ucr get ldap/base)" diff --git a/docker/ucs/timed-ucs.profile b/docker/ucs/timed-ucs.profile deleted file mode 100644 index 33894d04e..000000000 --- a/docker/ucs/timed-ucs.profile +++ /dev/null @@ -1,9 +0,0 @@ -hostname="timed-ucs" -domainname="example.com" -windows/domain="EXAMPLE" -ldap/base="dc=example,dc=com" -root_password="univention" - -server/role="domaincontroller_master" - -interfaces/eth0/type="dynamic" diff --git a/timed/settings.py b/timed/settings.py index 3f4351b25..e515fe1e7 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -173,36 +173,28 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): # Authentication definition -AUTH_LDAP_USER_ATTR_MAP = env.dict('DJANGO_AUTH_LDAP_USER_ATTR_MAP', default={ - 'first_name': 'givenName', - 'last_name': 'sn', - 'email': 'mail' -}) - -LDAP_BASE = env.str('DJANGO_LDAP_BASE', default='dc=example,dc=com') -AUTH_LDAP_SERVER_URI = env.str( - 'DJANGO_AUTH_LDAP_SERVER_URI', - default('ldap://localhost:389') -) -AUTH_LDAP_BIND_DN = env.str( - 'DJANGO_AUTH_LDAP_BIND_DN', - default('uid=Administrator,cn=users,{0}'.format(LDAP_BASE)) -) -AUTH_LDAP_PASSWORD = env.str( - 'DJANGO_AUTH_LDAP_PASSWORD', - default('univention') -) -AUTH_LDAP_USER_DN_TEMPLATE = env.str( - 'DJANGO_AUTH_LDAP_USER_DN_TEMPLATE', - default('uid=%(user)s,cn=users,{0}'.format(LDAP_BASE)) -) +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', +] -AUTH_USER_MODEL = 'employment.User' +AUTH_LDAP_ENABLED = env.dict('DJANGO_AUTH_LDAP_ENABLED', default=False) +if AUTH_LDAP_ENABLED: # pragma: todo cover + AUTH_LDAP_USER_ATTR_MAP = env.dict( + 'DJANGO_AUTH_LDAP_USER_ATTR_MAP', + default={ + 'first_name': 'givenName', + 'last_name': 'sn', + 'email': 'mail' + } + ) + + AUTH_LDAP_SERVER_URI = env.str('DJANGO_AUTH_LDAP_SERVER_URI') + AUTH_LDAP_BIND_DN = env.str('DJANGO_AUTH_LDAP_BIND_DN') + AUTH_LDAP_PASSWORD = env.str('DJANGO_AUTH_LDAP_PASSWORD') + AUTH_LDAP_USER_DN_TEMPLATE = env.str('DJANGO_AUTH_LDAP_USER_DN_TEMPLATE') + AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend') -AUTHENTICATION_BACKENDS = ( - 'django_auth_ldap.backend.LDAPBackend', - 'django.contrib.auth.backends.ModelBackend', -) +AUTH_USER_MODEL = 'employment.User' JWT_AUTH = { 'JWT_EXPIRATION_DELTA': datetime.timedelta(days=2), From ca95b9e4ab7bfaa29d2627cbda9e558f77405567 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 15 Sep 2017 11:05:22 +0200 Subject: [PATCH 207/980] Reduce docker cache layers --- Dockerfile | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9f79e3b1e..abec5dc13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ python-pip \ -&& rm -rf /var/lib/apt/lists/* - -RUN pip install uwsgi==2.0.15 - -RUN mkdir -p /var/www/static +&& rm -rf /var/lib/apt/lists/* \ +&& pip install uwsgi==2.0.15 ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static @@ -19,7 +16,8 @@ WORKDIR /app RUN make install -RUN ENV=docker ./manage.py collectstatic --noinput +RUN mkdir -p /var/www/static \ +&& ENV=docker ./manage.py collectstatic --noinput EXPOSE 80 CMD ["uwsgi"] From 6151f42f98508b938e28d6333bdc7cc251d3a065 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 19 Sep 2017 10:15:56 +0200 Subject: [PATCH 208/980] Bump to version 0.5.0 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 5e3252336..16aade4a0 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -__version__ = '0.4.0' +__version__ = '0.5.0' From e83e17e5706a536e7a73bc4264e7b86d6fbb2aac Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 21 Sep 2017 10:26:15 +0200 Subject: [PATCH 209/980] Split database url into different env vars. This simplifies containerization as it separates confidential data from unconfidential data. --- timed/settings.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/timed/settings.py b/timed/settings.py index e515fe1e7..69e9fdb16 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -24,9 +24,20 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): # Database definition -DATABASE_URL = default('psql://timed:timed@127.0.0.1:5432/timed') DATABASES = { - 'default': env.db(default=DATABASE_URL) + 'default': { + 'ENGINE': env.str( + 'DJANGO_DATABASE_ENGINE', + default='django.db.backends.postgresql_psycopg2' + ), + 'NAME': env.str('DJANGO_DATABASE_NAME', default='timed'), + 'USER': env.str('DJANGO_DATABASE_USER', default='timed'), + 'PASSWORD': env.str( + 'DJANGO_DATABASE_PASSWORD', default=default('timed') + ), + 'HOST': env.str('DJANGO_DATABASE_HOST', default='localhost'), + 'PORT': env.str('DJANGO_DATABASE_PORT', default='') + } } From 245fa8bc2092a1f02b0d3c5ff24437ca79b9a689 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 2 Oct 2017 12:57:10 +0200 Subject: [PATCH 210/980] Do not show archived tasks in frequently used tasks --- timed/projects/filters.py | 7 ++++++- timed/projects/tests/test_task.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index adc702a26..69b9ef4c0 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -63,7 +63,12 @@ def filter(self, qs, value): user = self.parent.request.user from_date = date.today() - timedelta(days=60) - qs = qs.filter(reports__user=user, reports__date__gt=from_date) + qs = qs.filter( + reports__user=user, + reports__date__gt=from_date, + archived=False, + project__archived=False + ) qs = qs.annotate(frequency=Count('reports')).order_by('-frequency') # limit number of results to given value qs = qs[:int(value)] diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 4dcba7706..0f0baa8ee 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -59,6 +59,18 @@ def test_task_my_most_frequent(self): ReportFactory.create_batch( 4, date=old_report_date, user=self.user, task=self.tasks[2] ) + # tasks[3] should not appear in result, as project is archived + self.tasks[3].project.archived = True + self.tasks[3].project.save() + ReportFactory.create_batch( + 4, date=report_date, user=self.user, task=self.tasks[3] + ) + # tasks[4] should not appear in result, as task is archived + self.tasks[4].archived = True + self.tasks[4].save() + ReportFactory.create_batch( + 4, date=report_date, user=self.user, task=self.tasks[4] + ) url = reverse('task-list') From a20a4b7eb6a0810d74108ab110d800d9bada63df Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 4 Oct 2017 10:08:59 +0200 Subject: [PATCH 211/980] Add estimated hours to redmine report --- timed/redmine/management/commands/redmine_report.py | 2 ++ timed/redmine/templates/redmine/weekly_report.txt | 1 + timed/redmine/tests/test_redmine_report.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index afc1d372c..e363dc676 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -55,6 +55,7 @@ def handle(self, *args, **options): ).annotate(total_hours=Sum('tasks__reports__duration')) for project in projects: + estimated_hours = project.estimated_time.total_seconds() / 3600 total_hours = project.total_hours.total_seconds() / 3600 try: issue = redmine.issue.get(project.redmine_project.issue_id) @@ -68,6 +69,7 @@ def handle(self, *args, **options): 'hours': hours.total_seconds() / 3600, 'last_days': last_days, 'total_hours': total_hours, + 'estimated_hours': estimated_hours, 'reports': reports }, using='text') issue.custom_fields = [{ diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/timed/redmine/templates/redmine/weekly_report.txt index 72402a0d2..b23c6ca98 100644 --- a/timed/redmine/templates/redmine/weekly_report.txt +++ b/timed/redmine/templates/redmine/weekly_report.txt @@ -4,6 +4,7 @@ Customer: {{project.customer.name}} Project: {{project.name}} Hours in last {{last_days}} days: {{hours}} Total hours: {{total_hours}} +Estimated hours: {{estimated_hours}} Reported in last {{last_days}} days: diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 648e2a23a..2d5d0a98b 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -23,6 +23,7 @@ def test_redmine_report(db, freezer, mocker): freezer.move_to('2017-07-28') report = ReportFactory.create(comment='ADSY <=> Other') report_hours = report.duration.total_seconds() / 3600 + estimated_hours = report.task.project.estimated_time.total_seconds() / 3600 RedmineProject.objects.create(project=report.task.project, issue_id=1000) # report not attached to redmine ReportFactory.create() @@ -36,6 +37,7 @@ def test_redmine_report(db, freezer, mocker): 'value': report_hours }] assert 'Total hours: {0}'.format(report_hours) in issue.notes + assert 'Estimated hours: {0}'.format(estimated_hours) in issue.notes assert 'Hours in last 7 days: {0}\n'.format(report_hours) in issue.notes assert '{0}\n'.format(report.comment) in issue.notes assert '{0}\n\n'.format(report.comment) not in issue.notes, ( From cefdccf9f0b1a7de037a8d885bf3eddbd470e601 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 4 Oct 2017 13:54:42 +0200 Subject: [PATCH 212/980] Remove duration on absence and calculate duration on the fly When employments changed duration of absence would need to be recalculated as well. However this was not the case leading to invalid worktime. Instead of recalculating absence on database it is less error prone to always calculate absence duration when needed. --- timed/employment/models.py | 19 ++-- timed/employment/serializers.py | 28 +++++- timed/tracking/factories.py | 13 --- .../0002_remove_absence_duration.py | 19 ++++ timed/tracking/models.py | 55 ++++-------- timed/tracking/serializers.py | 27 ++++-- timed/tracking/tests/test_absence.py | 89 +++++++++++++++++++ timed/tracking/tests/test_report.py | 29 +----- 8 files changed, 182 insertions(+), 97 deletions(-) create mode 100644 timed/tracking/migrations/0002_remove_absence_duration.py diff --git a/timed/employment/models.py b/timed/employment/models.py index 26cda2afb..cdc30d228 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -145,10 +145,6 @@ def with_user(self, user, start_date, end_date): WHEN at.fill_worktime THEN NULL ELSE used_join.used_days END AS used_days, - CASE - WHEN at.fill_worktime THEN used_join.used_duration - ELSE NULL - END AS used_duration, CASE WHEN at.fill_worktime THEN NULL ELSE credit_join.credit - used_join.used_days @@ -171,8 +167,7 @@ def with_user(self, user, start_date, end_date): LEFT JOIN ( SELECT at.id, - COUNT(a.id) AS used_days, - SUM(a.duration) AS used_duration + COUNT(a.id) AS used_days FROM {absencetype_table} AS at LEFT JOIN {absence_table} AS a ON ( a.type_id = at.id @@ -358,14 +353,14 @@ def calculate_worktime(self, start, end): timedelta() ) - absences = sum( - Absence.objects.filter( + absences = sum([ + absence.calculate_duration(self) + for absence in Absence.objects.filter( user=self.user, date__gte=start, - date__lte=end - ).values_list('duration', flat=True), - timedelta() - ) + date__lte=end, + ) + ], timedelta()) reported = reported_worktime + absences + overtime_credit diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index ac8341466..3bba9f8a3 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -1,16 +1,16 @@ """Serializers for the employment app.""" -from datetime import date, datetime +from datetime import date, datetime, timedelta from django.contrib.auth import get_user_model from django.utils.duration import duration_string from rest_framework.exceptions import PermissionDenied from rest_framework_json_api import relations -from rest_framework_json_api.serializers import (DurationField, IntegerField, - ModelSerializer, +from rest_framework_json_api.serializers import (IntegerField, ModelSerializer, SerializerMethodField) from timed.employment import models +from timed.tracking.models import Absence class UserSerializer(ModelSerializer): @@ -171,7 +171,7 @@ class UserAbsenceTypeSerializer(ModelSerializer): credit = IntegerField() used_days = IntegerField() - used_duration = DurationField() + used_duration = SerializerMethodField(source='get_used_duration') balance = IntegerField() user = relations.SerializerMethodResourceRelatedField( @@ -190,6 +190,26 @@ class UserAbsenceTypeSerializer(ModelSerializer): def get_user(self, instance): return get_user_model().objects.get(pk=instance.user_id) + def get_used_duration(self, instance): + # only calculate worktime if it fills up day + if not instance.fill_worktime: + return None + + absences = sum([ + absence.calculate_duration( + models.Employment.objects.get_at( + instance.user_id, absence.date + ) + ) + for absence in Absence.objects.filter( + user=instance.user_id, + date__gte=instance.start_date, + date__lte=instance.end_date, + type_id=instance.id + ) + ], timedelta()) + return duration_string(absences) + def get_absence_credits(self, instance): """Get the absence credits for the user and type.""" return models.AbsenceCredit.objects.filter( diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 03a0cd21c..fc6bc2361 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -8,7 +8,6 @@ from faker import Factory as FakerFactory from pytz import timezone -from timed.employment.models import Employment from timed.tracking import models tzinfo = timezone('Europe/Zurich') @@ -101,18 +100,6 @@ class AbsenceFactory(DjangoModelFactory): type = SubFactory('timed.employment.factories.AbsenceTypeFactory') date = Faker('date') - @lazy_attribute - def duration(self): - """Take the users employment worktime per day as duration. - - :return: The computed duration - :rtype: datetime.timedelta - """ - return Employment.objects.get_at( - self.user, - self.date - ).worktime_per_day - class Meta: """Meta informations for the absence factory.""" diff --git a/timed/tracking/migrations/0002_remove_absence_duration.py b/timed/tracking/migrations/0002_remove_absence_duration.py new file mode 100644 index 000000000..e74bce525 --- /dev/null +++ b/timed/tracking/migrations/0002_remove_absence_duration.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-10-04 08:35 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='absence', + name='duration', + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 3b37374cb..4d3bc29df 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -129,9 +129,6 @@ def save(self, *args, **kwargs): This rounds the duration of the report to the nearest 15 minutes. However, the duration must at least be 15 minutes long. - It also checks if an absence which should fill the expected worktime - exists on this date. If so, the duration of the absence needs to be - updated, by saving the absence again. """ self.duration = timedelta( seconds=max( @@ -142,13 +139,6 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) - for absence in Absence.objects.filter( - user=self.user, - date=self.date, - type__fill_worktime=True - ): - absence.save() - def __str__(self): """Represent the model as a string. @@ -172,40 +162,31 @@ class Absence(models.Model): comment = models.TextField(blank=True) date = models.DateField() - duration = models.DurationField(default=timedelta()) type = models.ForeignKey('employment.AbsenceType', related_name='absences') user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='absences') - def save(self, *args, **kwargs): - """Compute the duration of the absence and save it. - - The duration of an absence should be the worktime per day of the - employment. Unless an absence type should only fill the worktime (e.g - sickness), in which case the duration of the absence needs to fill the - difference between the reported time and the worktime per day. + def calculate_duration(self, employment): """ - from timed.employment.models import Employment - employment = Employment.objects.get_at(self.user, self.date) - - if self.type.fill_worktime: - worktime = sum( - Report.objects.filter( - date=self.date, - user=self.user - ).values_list('duration', flat=True), - timedelta() - ) + Calculate duration of absence with given employment. - self.duration = max( - timedelta(), - employment.worktime_per_day - worktime - ) - else: - self.duration = employment.worktime_per_day - - super().save(*args, **kwargs) + For fullday absences duration is equal worktime per day of employment + for absences which need to fill day calcuation needs to check + how much time has been reported on that day. + """ + if not self.type.fill_worktime: + return employment.worktime_per_day + + reports = Report.objects.filter(date=self.date, user=self.user) + data = reports.aggregate(reported_time=models.Sum('duration')) + reported_time = data['reported_time'] or timedelta() + if reported_time >= employment.worktime_per_day: + # prevent negative duration in case user already + # reported more time than worktime per day + return timedelta() + + return employment.worktime_per_day - reported_time class Meta: """Meta informations for the absence model.""" diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 5fef676aa..46d3e00e6 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -9,6 +9,7 @@ from rest_framework_json_api.serializers import (CurrentUserDefault, DurationField, ModelSerializer, + SerializerMethodField, ValidationError) from timed.employment.models import AbsenceType, Employment, PublicHoliday @@ -200,7 +201,7 @@ class Meta: class AbsenceSerializer(ModelSerializer): """Absence serializer.""" - duration = DurationField(read_only=True) + duration = SerializerMethodField(source='get_duration') type = ResourceRelatedField(queryset=AbsenceType.objects.all()) user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) @@ -209,6 +210,17 @@ class AbsenceSerializer(ModelSerializer): 'user': 'timed.employment.serializers.UserSerializer', } + def get_duration(self, instance): + try: + employment = Employment.objects.get_at( + instance.user, instance.date + ) + except Employment.DoesNotExist: + # absence is invalid if no employment exists on absence date + return duration_string(timedelta()) + + return duration_string(instance.calculate_duration(employment)) + def validate(self, data): """Validate the absence data. @@ -217,10 +229,15 @@ def validate(self, data): :returns: The validated data :rtype: dict """ - location = Employment.objects.get_at( - data.get('user'), - data.get('date') - ).location + try: + location = Employment.objects.get_at( + data.get('user'), + data.get('date') + ).location + except Employment.DoesNotExist: + raise ValidationError( + _('You can\'t create an absence on an unemployed day.') + ) if PublicHoliday.objects.filter( location_id=location.id, diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 2677f35ca..0f7d4aaf4 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -209,6 +209,53 @@ def test_absence_fill_worktime(self): assert result['data']['attributes']['duration'] == '03:00:00' + def test_absence_fill_worktime_reported_time_to_long(self): + """ + Verify absence fill worktime is zero when reported time is too long. + + Too long is defined when reported time is longer than worktime per day. + """ + date = datetime.date(2017, 5, 10) + type = AbsenceTypeFactory.create(fill_worktime=True) + employment = Employment.objects.get_at(self.user, date) + + employment.worktime_per_day = datetime.timedelta(hours=8) + employment.save() + + ReportFactory.create( + user=self.user, + date=date, + duration=datetime.timedelta(hours=8, minutes=30) + ) + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id + } + } + } + } + } + + url = reverse('absence-list') + + res = self.client.post(url, data) + + assert res.status_code == HTTP_201_CREATED + + result = self.result(res) + + assert result['data']['attributes']['duration'] == '00:00:00' + def test_absence_weekend(self): """Should not be able to create an absence on a weekend.""" type = AbsenceTypeFactory.create() @@ -271,3 +318,45 @@ def test_absence_public_holiday(self): res = self.client.post(url, data) assert res.status_code == HTTP_400_BAD_REQUEST + + +def test_absence_create_unemployed(auth_client): + """Test creation of absence fails on unemployed day.""" + date = datetime.date(2017, 5, 16) + type = AbsenceTypeFactory.create() + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id + } + } + } + } + } + + url = reverse('absence-list') + + res = auth_client.post(url, data) + assert res.status_code == HTTP_400_BAD_REQUEST + + +def test_absence_detail_unemployed(auth_client): + """Test creation of absence fails on unemployed day.""" + absence = AbsenceFactory.create(user=auth_client.user) + + url = reverse('absence-detail', args=[absence.id]) + + res = auth_client.get(url) + assert res.status_code == HTTP_200_OK + + json = res.json() + assert json['data']['attributes']['duration'] == '00:00:00' diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 0718656bf..af745d8e4 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -15,12 +15,11 @@ HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) -from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, - UserFactory) +from timed.employment.factories import UserFactory from timed.jsonapi_test_case import JSONAPIClient, JSONAPITestCase from timed.projects.factories import TaskFactory -from timed.tracking.factories import AbsenceFactory, ReportFactory -from timed.tracking.models import Absence, Report +from timed.tracking.factories import ReportFactory +from timed.tracking.models import Report class ReportTests(JSONAPITestCase): @@ -451,28 +450,6 @@ def test_report_round_duration(self): assert duration_string(report.duration) == '02:00:00' - def test_absence_update_on_create_report(self): - """Should update the absence after creating a new report.""" - task = TaskFactory.create() - type = AbsenceTypeFactory.create(fill_worktime=True) - day = date(2017, 5, 3) - - employment = EmploymentFactory.create(user=self.user, start_date=day) - - absence = AbsenceFactory.create(user=self.user, date=day, type=type) - - Report.objects.create( - user=self.user, - date=day, - task=task, - duration=timedelta(hours=1) - ) - - assert ( - Absence.objects.get(pk=absence.pk).duration == - employment.worktime_per_day - timedelta(hours=1) - ) - class TestReportHypo(TestCase): @given( From c3dd9e0235a21eb58124956193aac8e709472712 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 4 Oct 2017 14:03:00 +0200 Subject: [PATCH 213/980] Calculate sum of overtime credit and reported time on database This is much faster than getting all duration values and summing it up in python. --- timed/employment/models.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index cdc30d228..d24c4049e 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import AbstractUser, UserManager from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import functions +from django.db.models import Sum, functions from timed.models import WeekdaysField @@ -335,22 +335,20 @@ def calculate_worktime(self, start, end): expected_worktime = self.worktime_per_day * (workdays - holidays) - overtime_credit = sum( - OvertimeCredit.objects.filter( - user=self.user, - date__gte=start, - date__lte=end - ).values_list('duration', flat=True), - timedelta() - ) + overtime_credit_data = OvertimeCredit.objects.filter( + user=self.user, + date__gte=start, + date__lte=end + ).aggregate(total_duration=Sum('duration')) + overtime_credit = overtime_credit_data['total_duration'] or timedelta() - reported_worktime = sum( - Report.objects.filter( - user=self.user, - date__gte=start, - date__lte=end - ).values_list('duration', flat=True), - timedelta() + reported_worktime_data = Report.objects.filter( + user=self.user, + date__gte=start, + date__lte=end + ).aggregate(duration_total=Sum('duration')) + reported_worktime = ( + reported_worktime_data['duration_total'] or timedelta() ) absences = sum([ From 39c84205c5e6f25377622cf91db6be8a8d630c20 Mon Sep 17 00:00:00 2001 From: Michael Hofer Date: Thu, 5 Oct 2017 19:20:00 +0200 Subject: [PATCH 214/980] Use markdown code tags in redmine weekly report --- timed/redmine/templates/redmine/weekly_report.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/timed/redmine/templates/redmine/weekly_report.txt index b23c6ca98..fd06522f5 100644 --- a/timed/redmine/templates/redmine/weekly_report.txt +++ b/timed/redmine/templates/redmine/weekly_report.txt @@ -1,5 +1,5 @@ {% load float_hours %} -
+```
 Customer: {{project.customer.name}}
 Project: {{project.name}}
 Hours in last {{last_days}} days: {{hours}}
@@ -10,4 +10,4 @@ Estimated hours: {{estimated_hours}}
 Reported in last {{last_days}} days:
 {% for report in reports %}
 {{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment}}{% endfor %}
-
+``` From b78efd4a934d09c39a4afdcc35d5843774d9192b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 12 Sep 2017 16:48:28 +0200 Subject: [PATCH 215/980] Convert to and from datetime on ActivityBlock to time fields An activity is on a particular day so a block may on be added within this day. --- timed/employment/factories.py | 13 +-- timed/tracking/factories.py | 26 ++--- timed/tracking/filters.py | 4 +- .../migrations/0002_auto_20170912_1346.py | 52 ++++++++++ .../migrations/0003_auto_20170912_1347.py | 25 +++++ timed/tracking/models.py | 14 +-- timed/tracking/serializers.py | 34 ++++--- timed/tracking/tests/test_activity.py | 2 +- timed/tracking/tests/test_activity_block.py | 97 ++++++++++++------- 9 files changed, 178 insertions(+), 89 deletions(-) create mode 100644 timed/tracking/migrations/0002_auto_20170912_1346.py create mode 100644 timed/tracking/migrations/0003_auto_20170912_1347.py diff --git a/timed/employment/factories.py b/timed/employment/factories.py index ce931e2f8..766f2e5f2 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -17,18 +17,7 @@ class UserFactory(DjangoModelFactory): last_name = Faker('last_name') email = Faker('email') password = Faker('password', length=12) - - @lazy_attribute - def username(self): - """Generate a username from first and last name. - - :return: The generated username - :rtype: str - """ - return '{0}.{1}'.format( - self.first_name, - self.last_name, - ).lower() + username = Faker('uuid4') class Meta: """Meta informations for the user factory.""" diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index fc6bc2361..164b3c61d 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -5,15 +5,9 @@ from factory import Faker, SubFactory, lazy_attribute from factory.django import DjangoModelFactory -from faker import Factory as FakerFactory -from pytz import timezone from timed.tracking import models -tzinfo = timezone('Europe/Zurich') - -faker = FakerFactory.create() - class AttendanceFactory(DjangoModelFactory): """Attendance factory.""" @@ -73,19 +67,19 @@ class Meta: class ActivityBlockFactory(DjangoModelFactory): """Activity block factory.""" - activity = SubFactory(ActivityFactory) - from_datetime = Faker('date_time', tzinfo=tzinfo) + activity = SubFactory(ActivityFactory) + from_time = Faker('time_object') @lazy_attribute - def to_datetime(self): - """Generate a datetime based on the from_datetime. - - :return: The generated datetime - :rtype: datetime.datetime - """ - hours = randint(1, 5) + def from_time(self): + return datetime.time( + hour=randint(0, 22), + minute=randint(0, 59) + ) - return self.from_datetime + datetime.timedelta(hours=hours) + @lazy_attribute + def to_time(self): + return self.from_time.replace(hour=self.from_time.hour + 1) class Meta: """Meta informations for the activity block factory.""" diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 23f2f81a5..6955f31ef 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -34,7 +34,7 @@ class ActivityActiveFilter(Filter): """Filter to filter activities by being currently active or not. An activity is active, as soon as they have at least on activity - block which does not have to_datetime. + block which does not have to_time. """ @boolean_filter @@ -48,7 +48,7 @@ def filter(self, qs, value): """ return qs.filter( blocks__isnull=False, - blocks__to_datetime__exact=None + blocks__to_time__exact=None ).distinct() diff --git a/timed/tracking/migrations/0002_auto_20170912_1346.py b/timed/tracking/migrations/0002_auto_20170912_1346.py new file mode 100644 index 000000000..43338109a --- /dev/null +++ b/timed/tracking/migrations/0002_auto_20170912_1346.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-12 11:46 +from __future__ import unicode_literals + +from django.conf import settings +from django.db.models import F +from django.db import migrations, models +from pytz import timezone, utc + + +def migrate_activity_block_time(apps, schema_editor): + """ + Convert hours to Django time zone. + + Activity block time is in UTC but once it is converted + to time we actually want time as it would be represented + in settings.TIME_ZONE + """ + current_tz = timezone(settings.TIME_ZONE) + ActivityBlock = apps.get_model('tracking', 'ActivityBlock') + for block in ActivityBlock.objects.all(): + block.to_datetime = block.to_datetime.astimezone(current_tz).replace(tzinfo=utc) + block.from_datetime = block.from_datetime.astimezone(current_tz).replace(tzinfo=utc) + block.save() + + +def delete_invalid_blocks(apps, schema_editor): + """Delete blocks where to is earlier than from.""" + ActivityBlock = apps.get_model('tracking', 'ActivityBlock') + ActivityBlock.objects.filter(from_datetime__gt=F('to_datetime')).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0001_initial'), + ] + + operations = [ + migrations.RunPython(migrate_activity_block_time), + migrations.AlterField( + model_name='activityblock', + name='from_datetime', + field=models.TimeField(), + ), + migrations.AlterField( + model_name='activityblock', + name='to_datetime', + field=models.TimeField(blank=True, null=True), + ), + migrations.RunPython(delete_invalid_blocks), + ] diff --git a/timed/tracking/migrations/0003_auto_20170912_1347.py b/timed/tracking/migrations/0003_auto_20170912_1347.py new file mode 100644 index 000000000..7fe9c9ab6 --- /dev/null +++ b/timed/tracking/migrations/0003_auto_20170912_1347.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-12 11:47 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0002_auto_20170912_1346'), + ] + + operations = [ + migrations.RenameField( + model_name='activityblock', + old_name='from_datetime', + new_name='from_time', + ), + migrations.RenameField( + model_name='activityblock', + old_name='to_datetime', + new_name='to_time', + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 4d3bc29df..b4d05c819 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -31,7 +31,7 @@ def duration(self): :rtype: datetime.timedelta """ return self.blocks.all().aggregate( - duration=Sum(F('to_datetime') - F('from_datetime')) + duration=Sum(F('to_time') - F('from_time')) ).get('duration') def __str__(self): @@ -57,16 +57,12 @@ class ActivityBlock(models.Model): activity = models.ForeignKey('tracking.Activity', related_name='blocks') - from_datetime = models.DateTimeField() - to_datetime = models.DateTimeField(blank=True, null=True) + from_time = models.TimeField() + to_time = models.TimeField(blank=True, null=True) def __str__(self): - """Represent the model as a string. - - :return: The string representation - :rtype: str - """ - return '{1} ({0})'.format(self.activity, self.duration) + return '{0} ({1} - {2})'.format(self.activity, self.from_time, + self.to_time) class Attendance(models.Model): diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 46d3e00e6..4ee45b212 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -51,7 +51,6 @@ class Meta: class ActivityBlockSerializer(ModelSerializer): """Activity block serializer.""" - duration = DurationField(read_only=True) activity = ResourceRelatedField(queryset=models.Activity.objects.all()) included_serializers = { @@ -61,22 +60,26 @@ class ActivityBlockSerializer(ModelSerializer): def validate(self, data): """Validate the activity block. - Ensure that a user can only have one activity with an active block. + Ensure that a user can only have one activity with an active block + which doesn't end before it started. """ - if self.instance: - user = self.instance.activity.user - to_datetime = data.get('to_datetime', self.instance.to_datetime) - else: - user = data.get('activity').user - to_datetime = data.get('to_datetime', None) + instance = self.instance + from_time = data.get('from_time', instance and instance.from_time) + to_time = data.get('to_time', instance and instance.to_time) + user = instance and instance.activity.user or data.get('activity').user + # validate that there is only one active activity blocks = models.ActivityBlock.objects.filter(activity__user=user) + if blocks.filter(to_time__isnull=True) and to_time is None: + raise ValidationError( + _('A user can only have one active activity') + ) - if ( - blocks.filter(to_datetime__isnull=True) and - to_datetime is None - ): - raise ValidationError('A user can only have one active activity') + # validate that to is not before from + if to_time is not None and to_time < from_time: + raise ValidationError( + _('An activity block may not end before it starts.') + ) return data @@ -86,9 +89,8 @@ class Meta: model = models.ActivityBlock fields = [ 'activity', - 'duration', - 'from_datetime', - 'to_datetime', + 'from_time', + 'to_time', ] diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index f97eed504..8e479acf1 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -152,7 +152,7 @@ def test_activity_list_filter_active(self): activity = self.activities[0] block = ActivityBlockFactory.create(activity=activity) - block.to_datetime = None + block.to_time = None block.save() url = reverse('activity-list') diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py index 688e71ec6..c515da3b5 100644 --- a/timed/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -1,10 +1,9 @@ """Tests for the activity blocks endpoint.""" -from datetime import datetime +from datetime import time from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from pytz import timezone from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED) @@ -69,14 +68,13 @@ def test_activity_block_detail(self): def test_activity_block_create(self): """Should create a new activity block.""" activity = self.activity_blocks[0].activity - tz = timezone('Europe/Zurich') data = { 'data': { 'type': 'activity-blocks', 'id': None, 'attributes': { - 'from-datetime': datetime.now(tz).isoformat() + 'from-time': '08:00' }, 'relationships': { 'activity': { @@ -99,20 +97,21 @@ def test_activity_block_create(self): result = self.result(res) - assert not result['data']['attributes']['from-datetime'] is None - assert result['data']['attributes']['to-datetime'] is None + assert not result['data']['attributes']['from-time'] == '08:00' + assert result['data']['attributes']['to-time'] is None def test_activity_block_update(self): """Should update an existing activity block.""" activity_block = self.activity_blocks[0] - tz = timezone('Europe/Zurich') + activity_block.from_time = time(10, 0) + activity_block.save() data = { 'data': { 'type': 'activity-blocks', 'id': activity_block.id, 'attributes': { - 'to-datetime': datetime.now(tz).isoformat() + 'to-time': '23:59:00' } } } @@ -130,8 +129,8 @@ def test_activity_block_update(self): result = self.result(res) assert ( - result['data']['attributes']['to-datetime'] == - data['data']['attributes']['to-datetime'] + result['data']['attributes']['to-time'] == + data['data']['attributes']['to-time'] ) def test_activity_block_delete(self): @@ -148,34 +147,66 @@ def test_activity_block_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_204_NO_CONTENT - def test_activity_block_active_unique(self): - """Should not be able to have two active blocks.""" - block = self.activity_blocks[0] - tz = timezone('Europe/Zurich') - block.to_datetime = None - block.save() - - data = { - 'data': { - 'type': 'activity-blocks', - 'id': None, - 'attributes': { - 'from-datetime': datetime.now(tz).isoformat() - }, - 'relationships': { - 'activity': { - 'data': { - 'type': 'activities', - 'id': block.activity.id - } +def test_activity_block_active_unique(auth_client): + """Should not be able to have two active blocks.""" + activity = ActivityFactory.create(user=auth_client.user) + block = ActivityBlockFactory.create(to_time=None, activity=activity) + + data = { + 'data': { + 'type': 'activity-blocks', + 'id': None, + 'attributes': { + 'from-time': '08:00', + }, + 'relationships': { + 'activity': { + 'data': { + 'type': 'activities', + 'id': block.activity.id } } } } + } + + url = reverse('activity-block-list') + + res = auth_client.post(url, data) + + assert res.status_code == HTTP_400_BAD_REQUEST + json = res.json() + assert json['errors'][0]['detail'] == ( + 'A user can only have one active activity' + ) + + +def test_activity_block_to_before_from(auth_client): + """Test that to is not before from.""" + activity = ActivityFactory.create(user=auth_client.user) + block = ActivityBlockFactory.create( + from_time=time(7, 30), + to_time=None, + activity=activity + ) + + data = { + 'data': { + 'type': 'activity-blocks', + 'id': block.id, + 'attributes': { + 'to-time': '07:00', + } + } + } - url = reverse('activity-block-list') + url = reverse('activity-block-detail', args=[block.id]) - res = self.client.post(url, data) + res = auth_client.patch(url, data) - assert res.status_code == HTTP_400_BAD_REQUEST + assert res.status_code == HTTP_400_BAD_REQUEST + json = res.json() + assert json['errors'][0]['detail'] == ( + 'An activity block may not end before it starts.' + ) From 2536a9c5d0205c5adf8ece98a80c0781d5a6efb3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 2 Oct 2017 12:29:01 +0200 Subject: [PATCH 216/980] Check whether to_datetime is valid datetime before migrating it to_datetime may be None. --- timed/tracking/migrations/0002_auto_20170912_1346.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timed/tracking/migrations/0002_auto_20170912_1346.py b/timed/tracking/migrations/0002_auto_20170912_1346.py index 43338109a..8b633d197 100644 --- a/timed/tracking/migrations/0002_auto_20170912_1346.py +++ b/timed/tracking/migrations/0002_auto_20170912_1346.py @@ -19,7 +19,8 @@ def migrate_activity_block_time(apps, schema_editor): current_tz = timezone(settings.TIME_ZONE) ActivityBlock = apps.get_model('tracking', 'ActivityBlock') for block in ActivityBlock.objects.all(): - block.to_datetime = block.to_datetime.astimezone(current_tz).replace(tzinfo=utc) + if block.to_datetime is not None: + block.to_datetime = block.to_datetime.astimezone(current_tz).replace(tzinfo=utc) block.from_datetime = block.from_datetime.astimezone(current_tz).replace(tzinfo=utc) block.save() From ffcb19affa2164da915b31587e4ace6f1a240a23 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 5 Oct 2017 11:00:34 +0200 Subject: [PATCH 217/980] Do not set date automatically on activity User needs to have the possibility to create activities on different days. --- timed/tracking/factories.py | 1 + .../migrations/0004_auto_20171005_1057.py | 20 +++++++++++++++++++ timed/tracking/models.py | 2 +- timed/tracking/tests/test_activity.py | 2 ++ 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 timed/tracking/migrations/0004_auto_20171005_1057.py diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 164b3c61d..4138b2897 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -56,6 +56,7 @@ class ActivityFactory(DjangoModelFactory): comment = Faker('sentence') task = SubFactory('timed.projects.factories.TaskFactory') + date = Faker('date') user = SubFactory('timed.employment.factories.UserFactory') class Meta: diff --git a/timed/tracking/migrations/0004_auto_20171005_1057.py b/timed/tracking/migrations/0004_auto_20171005_1057.py new file mode 100644 index 000000000..cd29ecfa8 --- /dev/null +++ b/timed/tracking/migrations/0004_auto_20171005_1057.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-10-05 08:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0003_auto_20170912_1347'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='date', + field=models.DateField(), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index b4d05c819..631be19e2 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -15,7 +15,7 @@ class Activity(models.Model): """ comment = models.TextField(blank=True) - date = models.DateField(auto_now_add=True) + date = models.DateField() task = models.ForeignKey('projects.Task', null=True, blank=True, diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 8e479acf1..b21b4354a 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -74,6 +74,7 @@ def test_activity_create(self): 'type': 'activities', 'id': None, 'attributes': { + 'date': '2017-01-01', 'comment': 'Test activity' }, 'relationships': { @@ -197,6 +198,7 @@ def test_activity_create_no_task(self): 'type': 'activities', 'id': None, 'attributes': { + 'date': '2017-01-01', 'comment': 'Test activity' }, 'relationships': { From 3da783c6f14cac0ddf1d08c1243a9a5a0b82ce23 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 15:35:01 +0200 Subject: [PATCH 218/980] Solve database migration conflict --- ...move_absence_duration.py => 0005_remove_absence_duration.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename timed/tracking/migrations/{0002_remove_absence_duration.py => 0005_remove_absence_duration.py} (87%) diff --git a/timed/tracking/migrations/0002_remove_absence_duration.py b/timed/tracking/migrations/0005_remove_absence_duration.py similarity index 87% rename from timed/tracking/migrations/0002_remove_absence_duration.py rename to timed/tracking/migrations/0005_remove_absence_duration.py index e74bce525..dd61d63ed 100644 --- a/timed/tracking/migrations/0002_remove_absence_duration.py +++ b/timed/tracking/migrations/0005_remove_absence_duration.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ('tracking', '0001_initial'), + ('tracking', '0004_auto_20171005_1057'), ] operations = [ From 4c85f2c159eb4a883b5f38299c9d4179473cbb26 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 9 Oct 2017 11:30:09 +0200 Subject: [PATCH 219/980] Add filters and fields for the user overview --- timed/employment/filters.py | 7 +++++-- timed/employment/serializers.py | 11 ++++++++++- timed/employment/views.py | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index dcf2b4b99..b6c628155 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,7 +1,7 @@ """Filters for filtering the data of the employment app endpoints.""" -from django_filters import DateFilter, Filter, FilterSet +from django_filters import DateFilter, Filter, FilterSet, NumberFilter from timed.employment import models @@ -37,6 +37,9 @@ class Meta: class UserFilterSet(FilterSet): + active = NumberFilter(name='is_active') + supervisor = Filter(name='supervisors__id', lookup_expr='contains') + class Meta: model = models.User - fields = ['is_active'] + fields = ['active', 'supervisor'] diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 3bba9f8a3..2e3224cc7 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -76,6 +76,10 @@ def validate(self, data): return data included_serializers = { + 'supervisors': + 'timed.employment.serializers.UserSerializer', + 'supervisees': + 'timed.employment.serializers.UserSerializer', 'employments': 'timed.employment.serializers.EmploymentSerializer', 'user_absence_types': @@ -94,9 +98,12 @@ class Meta: 'employments', 'worktime_balance', 'is_staff', + 'is_superuser', 'is_active', 'user_absence_types', - 'tour_done' + 'tour_done', + 'supervisors', + 'supervisees' ] read_only_fields = [ 'username', @@ -104,6 +111,8 @@ class Meta: 'last_name', 'is_staff', 'is_active', + 'supervisors', + 'supervisees' ] diff --git a/timed/employment/views.py b/timed/employment/views.py index 85b1b04de..65fddedc9 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -20,6 +20,7 @@ class UserViewSet(mixins.RetrieveModelMixin, serializer_class = serializers.UserSerializer filter_class = filters.UserFilterSet + search_fields = ('username', 'first_name', 'last_name') def get_queryset(self): return get_user_model().objects.prefetch_related('employments') From af4f3685d7c25c7833988782793a6c3277c8c23b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 9 Oct 2017 13:19:32 +0200 Subject: [PATCH 220/980] Ignore sissing docstring in __init__ --- .flake8 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 5ca67f793..43785de01 100644 --- a/.flake8 +++ b/.flake8 @@ -17,7 +17,9 @@ ignore = # Missing docstring in magic method D105, # Missing docstring in public package - D106 + D106, + # Missing docstring in __init__ + D107 exclude = manage.py, From aba8acd080dba731362d98b7d2490cd2ab58d527 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 9 Oct 2017 14:42:25 +0200 Subject: [PATCH 221/980] Add test for supervisor filter --- timed/employment/tests/test_user.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 75c15b332..08abf16d7 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -351,3 +351,19 @@ def test_user_worktime_balance(auth_client): result2['data']['attributes']['worktime-balance'] == duration_string(timedelta(hours=28) - expected_worktime) ) + + +def test_user_supervisor_filter(auth_client): + """Should filter users by supervisor.""" + supervisees = UserFactory.create_batch(5) + + UserFactory.create_batch(5) + + auth_client.user.supervisees.add(*supervisees) + auth_client.user.save() + + res = auth_client.get(reverse('user-list'), { + 'supervisor': auth_client.user.id + }) + + assert len(res.json()['data']) == 5 From a5e148787e06aba4b53f0c67b300a5abfde19fca Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Oct 2017 11:49:43 +0200 Subject: [PATCH 222/980] Adjust AUTH_LDAP_ENABLED to be a bool option --- timed/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index 69e9fdb16..2eda08f37 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -188,7 +188,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'django.contrib.auth.backends.ModelBackend', ] -AUTH_LDAP_ENABLED = env.dict('DJANGO_AUTH_LDAP_ENABLED', default=False) +AUTH_LDAP_ENABLED = env.bool('DJANGO_AUTH_LDAP_ENABLED', default=False) if AUTH_LDAP_ENABLED: # pragma: todo cover AUTH_LDAP_USER_ATTR_MAP = env.dict( 'DJANGO_AUTH_LDAP_USER_ATTR_MAP', From 02ebe0ca3d724d038a4c5f22d9e8e03033a298df Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Oct 2017 12:54:59 +0200 Subject: [PATCH 223/980] Bump to version 0.6.0 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 16aade4a0..3ca80bc43 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -__version__ = '0.5.0' +__version__ = '0.6.0' From 522b524ebf5d1787bbbbc4dc497d1c4bbb4ed4d3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Oct 2017 13:55:48 +0200 Subject: [PATCH 224/980] Add cost center model Needed to define how cost of projects and tasks are allocated. --- timed/projects/admin.py | 6 +++ .../migrations/0006_auto_20171010_1423.py | 37 +++++++++++++++++++ timed/projects/models.py | 19 ++++++++++ 3 files changed, 62 insertions(+) create mode 100644 timed/projects/migrations/0006_auto_20171010_1423.py diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 4367e8202..5772e77c5 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -26,6 +26,12 @@ class BillingType(admin.ModelAdmin): search_fields = ['name'] +@admin.register(models.CostCenter) +class CostCenter(admin.ModelAdmin): + list_display = ['name', 'reference'] + search_fields = ['name'] + + class TaskForm(forms.ModelForm): """ Task form making sure that initial forms are marked as changed. diff --git a/timed/projects/migrations/0006_auto_20171010_1423.py b/timed/projects/migrations/0006_auto_20171010_1423.py new file mode 100644 index 000000000..689e2a506 --- /dev/null +++ b/timed/projects/migrations/0006_auto_20171010_1423.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-10 12:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0005_auto_20170907_0938'), + ] + + operations = [ + migrations.CreateModel( + name='CostCenter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('reference', models.CharField(blank=True, max_length=255, null=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='project', + name='cost_center', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='projects.CostCenter'), + ), + migrations.AddField( + model_name='task', + name='cost_center', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='projects.CostCenter'), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 331a4560d..450ae5900 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -33,6 +33,19 @@ class Meta: ordering = ['name'] +class CostCenter(models.Model): + """Cost center defining how cost of projects and tasks are allocated.""" + + name = models.CharField(max_length=255, unique=True) + reference = models.CharField(max_length=255, unique=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ['name'] + + class BillingType(models.Model): """Billing type defining how a project, resp. reports are being billed.""" @@ -64,6 +77,9 @@ class Project(models.Model): billing_type = models.ForeignKey(BillingType, on_delete=models.SET_NULL, blank=True, null=True, related_name='projects') + cost_center = models.ForeignKey(CostCenter, on_delete=models.SET_NULL, + blank=True, null=True, + related_name='projects') reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='reviews') @@ -93,6 +109,9 @@ class Task(models.Model): archived = models.BooleanField(default=False) project = models.ForeignKey('projects.Project', related_name='tasks') + cost_center = models.ForeignKey(CostCenter, on_delete=models.SET_NULL, + blank=True, null=True, + related_name='tasks') def __str__(self): """Represent the model as a string. From e6d8e6541fa3c2d5a53da4b7684ba7a95748ed75 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Oct 2017 14:32:09 +0200 Subject: [PATCH 225/980] Add cost center and billing type to export --- timed/projects/factories.py | 11 ++++++++ timed/projects/models.py | 2 +- timed/tracking/tests/test_report.py | 2 +- timed/tracking/views.py | 39 ++++++++++++++++++++++++++--- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/timed/projects/factories.py b/timed/projects/factories.py index 51dcca171..55cff6034 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -28,6 +28,14 @@ class Meta: model = models.BillingType +class CostCenterFactory(DjangoModelFactory): + name = Faker('uuid4') + reference = Faker('uuid4') + + class Meta: + model = models.CostCenter + + class ProjectFactory(DjangoModelFactory): """Project factory.""" @@ -36,6 +44,8 @@ class ProjectFactory(DjangoModelFactory): archived = False comment = Faker('sentence') customer = SubFactory('timed.projects.factories.CustomerFactory') + cost_center = SubFactory('timed.projects.factories.CostCenterFactory') + billing_type = SubFactory('timed.projects.factories.BillingTypeFactory') class Meta: """Meta informations for the project factory.""" @@ -50,6 +60,7 @@ class TaskFactory(DjangoModelFactory): estimated_time = Faker('time_delta') archived = False project = SubFactory('timed.projects.factories.ProjectFactory') + cost_center = SubFactory('timed.projects.factories.CostCenterFactory') class Meta: """Meta informations for the task factory.""" diff --git a/timed/projects/models.py b/timed/projects/models.py index 450ae5900..afc86d861 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -37,7 +37,7 @@ class CostCenter(models.Model): """Cost center defining how cost of projects and tasks are allocated.""" name = models.CharField(max_length=255, unique=True) - reference = models.CharField(max_length=255, unique=True) + reference = models.CharField(max_length=255, blank=True, null=True) def __str__(self): return self.name diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index af745d8e4..e48cb6617 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -481,7 +481,7 @@ def test_report_export(self, file_type, reports): client.login('test', '1234qwer') url = reverse('report-export') - with self.assertNumQueries(4): + with self.assertNumQueries(2): user_res = client.get(url, data={ 'file_type': file_type }) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 71275451a..b7eecf8dc 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -101,13 +101,44 @@ class ReportViewSet(ModelViewSet): 'not_billable' ) + def _extract_cost_center(self, report): + """ + Extract cost center from given report. + + Cost center of task is prioritized higher than of + project. + """ + name = '' + + if report.task.project.cost_center: + name = report.task.project.cost_center.name + + if report.task.cost_center: + name = report.task.project.name + + return name + + def _extract_billing_type(self, report): + """Extract billing type from given report.""" + name = '' + + if report.task.project.billing_type: + name = report.task.project.billing_type.name + + return name + @list_route() def export(self, request): """Export filtered reports to given file format.""" - queryset = self.filter_queryset(self.get_queryset()) + queryset = self.get_queryset().select_related( + 'task__project__billing_type', + 'task__cost_center', 'task__project__cost_center' + ) + queryset = self.filter_queryset(queryset) colnames = [ 'Date', 'Duration', 'Customer', - 'Project', 'Task', 'User', 'Comment' + 'Project', 'Task', 'User', 'Comment', + 'Billing Type', 'Cost Center' ] content = [ [ @@ -118,6 +149,8 @@ def export(self, request): report.task.name, report.user.username, report.comment, + self._extract_billing_type(report), + self._extract_cost_center(report), ] for report in queryset ] @@ -162,7 +195,7 @@ def get_queryset(self): 'task', 'user', 'activity' - ).prefetch_related('task__project', 'task__project__customer') + ).select_related('task__project', 'task__project__customer') class AbsenceViewSet(ModelViewSet): From 7a72050d484532ddc463a1e054e836d2f250fe24 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Oct 2017 14:50:30 +0200 Subject: [PATCH 226/980] Add cost center to project and task end point --- timed/projects/serializers.py | 23 ++++++++++++++++++----- timed/projects/tests/test_cost_center.py | 15 +++++++++++++++ timed/projects/urls.py | 1 + timed/projects/views.py | 12 ++++++++++-- 4 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 timed/projects/tests/test_cost_center.py diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 73a84aa05..58f83e5f9 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -36,6 +36,15 @@ class Meta: ] +class CostCenterSerializer(ModelSerializer): + class Meta: + model = models.CostCenter + fields = [ + 'name', + 'reference' + ] + + class ProjectSerializer(ModelSerializer): """Project serializer.""" @@ -45,8 +54,9 @@ class ProjectSerializer(ModelSerializer): ) included_serializers = { - 'customer': 'timed.projects.serializers.CustomerSerializer', - 'billing_type': 'timed.projects.serializers.BillingTypeSerializer' + 'customer': 'timed.projects.serializers.CustomerSerializer', + 'billing_type': 'timed.projects.serializers.BillingTypeSerializer', + 'cost_center': 'timed.projects.serializers.CostCenterSerializer' } def get_root_meta(self, resource, many): @@ -71,7 +81,8 @@ class Meta: 'estimated_time', 'archived', 'customer', - 'billing_type' + 'billing_type', + 'cost_center' ] @@ -81,8 +92,9 @@ class TaskSerializer(ModelSerializer): project = ResourceRelatedField(queryset=models.Project.objects.all()) included_serializers = { - 'activities': 'timed.tracking.serializers.ActivitySerializer', - 'project': 'timed.projects.serializers.ProjectSerializer' + 'activities': 'timed.tracking.serializers.ActivitySerializer', + 'project': 'timed.projects.serializers.ProjectSerializer', + 'cost_center': 'timed.projects.serializers.CostCenterSerializer' } def get_root_meta(self, resource, many): @@ -106,4 +118,5 @@ class Meta: 'estimated_time', 'archived', 'project', + 'cost_center' ] diff --git a/timed/projects/tests/test_cost_center.py b/timed/projects/tests/test_cost_center.py new file mode 100644 index 000000000..66e034ca8 --- /dev/null +++ b/timed/projects/tests/test_cost_center.py @@ -0,0 +1,15 @@ +from django.core.urlresolvers import reverse +from rest_framework.status import HTTP_200_OK + +from timed.projects.factories import CostCenterFactory + + +def test_cost_center_list(auth_client): + cost_center = CostCenterFactory.create() + url = reverse('cost-center-list') + + res = auth_client.get(url) + assert res.status_code == HTTP_200_OK + json = res.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(cost_center.id) diff --git a/timed/projects/urls.py b/timed/projects/urls.py index c57c1b656..370d8b1d6 100644 --- a/timed/projects/urls.py +++ b/timed/projects/urls.py @@ -11,5 +11,6 @@ r.register(r'customers', views.CustomerViewSet, 'customer') r.register(r'tasks', views.TaskViewSet, 'task') r.register(r'billing-types', views.BillingTypeViewSet, 'billing-type') +r.register(r'cost-centers', views.CostCenterViewSet, 'cost-center') urlpatterns = r.urls diff --git a/timed/projects/views.py b/timed/projects/views.py index 313ef0b2c..7ea40792c 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -31,6 +31,14 @@ def get_queryset(self): return models.BillingType.objects.all() +class CostCenterViewSet(ReadOnlyModelViewSet): + serializer_class = serializers.CostCenterSerializer + ordering = 'name' + + def get_queryset(self): + return models.CostCenter.objects.all() + + class ProjectViewSet(ReadOnlyModelViewSet): """Project view set.""" @@ -45,7 +53,7 @@ def get_queryset(self): :rtype: QuerySet """ return models.Project.objects.select_related( - 'customer' + 'customer', 'billing_type', 'cost_center' ) @@ -63,7 +71,7 @@ def get_queryset(self): :rtype: QuerySet """ return models.Task.objects.select_related( - 'project' + 'project', 'cost_center' ) def filter_queryset(self, queryset): From cc1dfec9db0129130b2fefa8dadefd4021c3f701 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Oct 2017 16:22:56 +0200 Subject: [PATCH 227/980] Add cost center filter to report, task and project end points --- timed/projects/filters.py | 4 +++- timed/tracking/filters.py | 15 +++++++++++++++ timed/tracking/tests/test_report.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 69b9ef4c0..c615ce9e2 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -35,6 +35,7 @@ class Meta: 'archived', 'customer', 'billing_type', + 'cost_center', 'reference' ] @@ -90,5 +91,6 @@ class Meta: 'archived', 'project', 'my_most_frequent', - 'reference' + 'reference', + 'cost_center' ] diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 6955f31ef..f7c4bd95c 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -2,6 +2,7 @@ from functools import wraps +from django.db.models import Q from django_filters import DateFilter, Filter, FilterSet, NumberFilter from timed.tracking import models @@ -97,6 +98,20 @@ class ReportFilterSet(FilterSet): not_verified = NumberFilter(name='verified_by', lookup_expr='isnull') reviewer = NumberFilter(name='task__project__reviewers') billing_type = NumberFilter(name='task__project__billing_type') + cost_center = NumberFilter(name='cost_center', method='filter_cost_center') + + def filter_cost_center(self, queryset, name, value): + """ + Filter report by cost center. + + Cost center on task has higher priority over project cost + center. + """ + return queryset.filter( + Q(task__cost_center=value) | + Q(task__project__cost_center=value) & + Q(task__cost_center__isnull=True) + ) class Meta: """Meta information for the report filter set.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index e48cb6617..bb07865e9 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -17,7 +17,8 @@ from timed.employment.factories import UserFactory from timed.jsonapi_test_case import JSONAPIClient, JSONAPITestCase -from timed.projects.factories import TaskFactory +from timed.projects.factories import (CostCenterFactory, ProjectFactory, + TaskFactory) from timed.tracking.factories import ReportFactory from timed.tracking.models import Report @@ -116,7 +117,6 @@ def test_report_list_verify_page(self): assert len(result['data']) == 5 def test_report_export_missing_type(self): - """Should respond with a list of filtered reports.""" url = reverse('report-export') user_res = self.client.get(url, data={ @@ -502,3 +502,28 @@ def test_report_list_no_result(admin_client): assert res.status_code == HTTP_200_OK json = res.json() assert json['meta']['total-time'] == '00:00:00' + + +def test_report_list_filter_cost_center(auth_client): + cost_center = CostCenterFactory.create() + # 1st valid case: report with task of given cost center + # but different project cost center + task = TaskFactory.create(cost_center=cost_center) + report_task = ReportFactory.create(task=task) + # 2nd valid case: report with project of given cost center + project = ProjectFactory.create(cost_center=cost_center) + task = TaskFactory.create(cost_center=None, project=project) + report_project = ReportFactory.create(task=task) + # Invalid case: report without cost center + project = ProjectFactory.create(cost_center=None) + task = TaskFactory.create(cost_center=None, project=project) + ReportFactory.create(task=task) + + url = reverse('report-list') + + res = auth_client.get(url, data={'cost_center': cost_center.id}) + assert res.status_code == HTTP_200_OK + json = res.json() + assert len(json['data']) == 2 + ids = {int(entry['id']) for entry in json['data']} + assert {report_task.id, report_project.id} == ids From b626ca02201a6ef702fe129f60a45acc1dc9b246 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 10:26:29 +0200 Subject: [PATCH 228/980] Integrate current balance and balance in shorttime report --- .../commands/notify_supervisors_shorttime.py | 21 ++++++++++++++++--- .../mail/notify_supervisor_shorttime.txt | 2 +- .../test_notify_supervisors_shorttime.py | 9 +++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index a83843dcb..fbf8fa961 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -74,10 +74,14 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): Get supervisees which reported less hours than they should have. :return: dict mapping all supervisees with shorttime with tuple of - reported, expected, balance and actual ratio. + reported, expected, balance, actual ratio and current balance. """ supervisees_shorttime = {} supervisees = get_user_model().objects.all_supervisees() + + today = date.today() + start_year = date(today.year, 1, 1) + for supervisee in supervisees: worktime = supervisee.calculate_worktime(start, end) reported, expected, balance = worktime @@ -90,7 +94,10 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): self._decimal_hours(reported), self._decimal_hours(expected), self._decimal_hours(balance), - supervisee_ratio + supervisee_ratio, + self._decimal_hours( + supervisee.calculate_worktime(start_year, today)[2] + ), ) return supervisees_shorttime @@ -121,7 +128,15 @@ def _notify_supervisors(self, start, end, ratio, supervisees): 'end': end, 'ratio': ratio, # format: - # [(user, (reported, expected, balance, ratio)), ...] + # [( + # user, ( + # reported, + # expected, + # balance, + # ratio, + # current_balance + # ) + # ), ...] 'suspects': suspects_shorttime }, using='text' ) diff --git a/timed/reports/templates/mail/notify_supervisor_shorttime.txt b/timed/reports/templates/mail/notify_supervisor_shorttime.txt index 971e89fb3..7b080c218 100644 --- a/timed/reports/templates/mail/notify_supervisor_shorttime.txt +++ b/timed/reports/templates/mail/notify_supervisor_shorttime.txt @@ -3,5 +3,5 @@ Time range: {{start}} - {{end}} Ratio: {{ratio}} {% for suspect, worktime in suspects %} -{{suspect.get_full_name}} {{worktime.0}}/{{worktime.1}} (Ratio {{worktime.3|floatformat:2}} Balance {{worktime.2}}) +{{suspect.get_full_name}} {{worktime.0}}/{{worktime.1}} (Ratio {{worktime.3|floatformat:2}} Range Balance {{worktime.2}} Balance {{worktime.4}}) {% endfor %} diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py index 28101a09d..b372a2b0c 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -11,7 +11,7 @@ @pytest.mark.freeze_time('2017-7-27') def test_notify_supervisors(db, mailoutbox): """Test time range 2017-7-17 till 2017-7-23.""" - start = date(2017, 1, 1) + start = date(2017, 7, 14) # supervisee with short time supervisee = UserFactory.create() supervisor = UserFactory.create() @@ -35,8 +35,11 @@ def test_notify_supervisors(db, mailoutbox): assert mail.to == [supervisor.email] body = mail.body assert 'Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9' in body - expected = '{0} 35.0/42.5 (Ratio 0.82 Balance -7.5)'.format( - supervisee.get_full_name()) + expected = ( + '{0} 35.0/42.5 (Ratio 0.82 Range Balance -7.5 Balance -15.0)' + ).format( + supervisee.get_full_name() + ) assert expected in body From be45dca745ebe36089a65fa76739b79cb8ee9c60 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 11:03:55 +0200 Subject: [PATCH 229/980] Calculate balance on end day of shorttime report --- .../management/commands/notify_supervisors_shorttime.py | 5 ++--- timed/reports/tests/test_notify_supervisors_shorttime.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index fbf8fa961..a88b4adc4 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -79,8 +79,7 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): supervisees_shorttime = {} supervisees = get_user_model().objects.all_supervisees() - today = date.today() - start_year = date(today.year, 1, 1) + start_year = date(end.year, 1, 1) for supervisee in supervisees: worktime = supervisee.calculate_worktime(start, end) @@ -96,7 +95,7 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): self._decimal_hours(balance), supervisee_ratio, self._decimal_hours( - supervisee.calculate_worktime(start_year, today)[2] + supervisee.calculate_worktime(start_year, end)[2] ), ) diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py index b372a2b0c..3debe0399 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -36,7 +36,7 @@ def test_notify_supervisors(db, mailoutbox): body = mail.body assert 'Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9' in body expected = ( - '{0} 35.0/42.5 (Ratio 0.82 Range Balance -7.5 Balance -15.0)' + '{0} 35.0/42.5 (Ratio 0.82 Range Balance -7.5 Balance -9.0)' ).format( supervisee.get_full_name() ) From 5bce0eb0db6320d5a6b92107f24a88901a9a3d87 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 11:25:15 +0200 Subject: [PATCH 230/980] Use Delta as difference between reported and expected time --- timed/reports/templates/mail/notify_supervisor_shorttime.txt | 2 +- timed/reports/tests/test_notify_supervisors_shorttime.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/reports/templates/mail/notify_supervisor_shorttime.txt b/timed/reports/templates/mail/notify_supervisor_shorttime.txt index 7b080c218..050e7df65 100644 --- a/timed/reports/templates/mail/notify_supervisor_shorttime.txt +++ b/timed/reports/templates/mail/notify_supervisor_shorttime.txt @@ -3,5 +3,5 @@ Time range: {{start}} - {{end}} Ratio: {{ratio}} {% for suspect, worktime in suspects %} -{{suspect.get_full_name}} {{worktime.0}}/{{worktime.1}} (Ratio {{worktime.3|floatformat:2}} Range Balance {{worktime.2}} Balance {{worktime.4}}) +{{suspect.get_full_name}} {{worktime.0}}/{{worktime.1}} (Ratio {{worktime.3|floatformat:2}} Delta {{worktime.2}} Balance {{worktime.4}}) {% endfor %} diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py index 3debe0399..68efc90b8 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -36,7 +36,7 @@ def test_notify_supervisors(db, mailoutbox): body = mail.body assert 'Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9' in body expected = ( - '{0} 35.0/42.5 (Ratio 0.82 Range Balance -7.5 Balance -9.0)' + '{0} 35.0/42.5 (Ratio 0.82 Delta -7.5 Balance -9.0)' ).format( supervisee.get_full_name() ) From af823121dea0a466e95eed831567fc2542d17f56 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 11:30:05 +0200 Subject: [PATCH 231/980] Use dict for suspect attributes --- timed/employment/models.py | 4 +-- .../commands/notify_supervisors_shorttime.py | 34 +++++++------------ .../mail/notify_supervisor_shorttime.txt | 2 +- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index d24c4049e..fbb186659 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -302,7 +302,7 @@ def calculate_worktime(self, start, end): :param start: calculate worktime starting on given day. :param end: calculate worktime till given day - :returns: tuple of 3 values reported, expected and balance in given + :returns: tuple of 3 values reported, expected and delta in given time frame """ from timed.tracking.models import Absence, Report @@ -408,7 +408,7 @@ def calculate_worktime(self, start, end): :param start: calculate worktime starting on given day. :param end: calculate worktime till given day - :returns: tuple of 3 values reported, expected and balance in given + :returns: tuple of 3 values reported, expected and delta in given time frame """ employments = Employment.objects.for_user(self, start, end) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index a88b4adc4..7553420f9 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -73,8 +73,8 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): """ Get supervisees which reported less hours than they should have. - :return: dict mapping all supervisees with shorttime with tuple of - reported, expected, balance, actual ratio and current balance. + :return: dict mapping all supervisees with shorttime with dict of + reported, expected, delta, actual ratio and balance. """ supervisees_shorttime = {} supervisees = get_user_model().objects.all_supervisees() @@ -83,21 +83,21 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): for supervisee in supervisees: worktime = supervisee.calculate_worktime(start, end) - reported, expected, balance = worktime + reported, expected, delta = worktime if expected == timedelta(0): continue supervisee_ratio = reported / expected if supervisee_ratio < ratio: - supervisees_shorttime[supervisee.id] = ( - self._decimal_hours(reported), - self._decimal_hours(expected), - self._decimal_hours(balance), - supervisee_ratio, - self._decimal_hours( + supervisees_shorttime[supervisee.id] = { + 'reported': self._decimal_hours(reported), + 'expected': self._decimal_hours(expected), + 'delta': self._decimal_hours(delta), + 'ratio': supervisee_ratio, + 'balance': self._decimal_hours( supervisee.calculate_worktime(start_year, end)[2] ), - ) + } return supervisees_shorttime @@ -106,8 +106,8 @@ def _notify_supervisors(self, start, end, ratio, supervisees): Notify supervisors about their supervisees. :param supervisees: dict whereas key is id of supervisee and - value as a worktime tuple of - reported, expected, balance and ratio + value as a worktime dict of + reported, expected, delta, ratio and balance """ supervisors = get_user_model().objects.all_supervisors() subject = '[Timed] Report supervisees with shorttime' @@ -126,16 +126,6 @@ def _notify_supervisors(self, start, end, ratio, supervisees): 'start': start, 'end': end, 'ratio': ratio, - # format: - # [( - # user, ( - # reported, - # expected, - # balance, - # ratio, - # current_balance - # ) - # ), ...] 'suspects': suspects_shorttime }, using='text' ) diff --git a/timed/reports/templates/mail/notify_supervisor_shorttime.txt b/timed/reports/templates/mail/notify_supervisor_shorttime.txt index 050e7df65..469f95fde 100644 --- a/timed/reports/templates/mail/notify_supervisor_shorttime.txt +++ b/timed/reports/templates/mail/notify_supervisor_shorttime.txt @@ -3,5 +3,5 @@ Time range: {{start}} - {{end}} Ratio: {{ratio}} {% for suspect, worktime in suspects %} -{{suspect.get_full_name}} {{worktime.0}}/{{worktime.1}} (Ratio {{worktime.3|floatformat:2}} Delta {{worktime.2}} Balance {{worktime.4}}) +{{suspect.get_full_name}} {{worktime.reported}}/{{worktime.expected}} (Ratio {{worktime.ratio|floatformat:2}} Delta {{worktime.delta}} Balance {{worktime.balance}}) {% endfor %} From f22228bf4a62741cac62525b59e42aba1aef7e90 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 12:40:16 +0200 Subject: [PATCH 232/980] Add option to configure server email address --- timed/settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/timed/settings.py b/timed/settings.py index 2eda08f37..cd6b28f00 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -242,6 +242,11 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): default('webmaster@localhost') ) +SERVER_EMAIL = env.str( + 'DJANGO_SERVER_EMAIL', + default('root@localhost') +) + def parse_admins(admins): """ From 8c7371e47de4f5e96a98c4e76bb64e9861e955d7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 14:33:53 +0200 Subject: [PATCH 233/980] Complete integration of redmine and subscription into timed-backend --- dev_requirements.txt | 5 ++++- setup.py | 2 ++ timed/projects/admin.py | 13 ++++++++++++- timed/redmine/admin.py | 16 +--------------- timed/redmine/tests/test_redmine_report.py | 4 ++-- timed/settings.py | 13 +++++++++++++ timed/subscription/admin.py | 12 ------------ 7 files changed, 34 insertions(+), 31 deletions(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 6d07d0e9d..4d27c33d5 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -13,7 +13,10 @@ ipdb isort pytest pytest-cov -pytest-django +pytest-mock==1.6.2 +# once newer version than 3.1.2 has been released +# pytest-django can be imported through pypi again +git+https://github.com/pytest-dev/pytest-django@21492af pytest-freezegun==0.1.0 sphinx sphinx_rtd_theme diff --git a/setup.py b/setup.py index 326f98a5a..dc77ffade 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,8 @@ def find_data(packages, extensions): 'pyexcel-xlsx==0.4.1', 'django-environ==0.4.3', 'rest_condition==1.0.3', + 'django-money==0.11.4', + 'python-redmine==2.0.2', ), keywords='timetracking', url='https://adfinis-sygroup.ch/', diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 4367e8202..bc080797a 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -7,6 +7,9 @@ from timed.forms import DurationInHoursField from timed.projects import models +from timed.redmine.admin import RedmineProjectInline +from timed.subscription.admin import (CustomerPasswordInline, + SubscriptionProjectInline) @admin.register(models.Customer) @@ -15,6 +18,9 @@ class CustomerAdmin(admin.ModelAdmin): list_display = ['name'] search_fields = ['name'] + inlines = [ + CustomerPasswordInline + ] def has_delete_permission(self, request, obj=None): return obj and not obj.projects.exists() @@ -97,7 +103,12 @@ class ProjectAdmin(admin.ModelAdmin): list_filter = ['customer'] search_fields = ['name', 'customer__name'] - inlines = [TaskInline, ReviewerInline] + inlines = [ + TaskInline, + ReviewerInline, + RedmineProjectInline, + SubscriptionProjectInline + ] exclude = ('reviewers', ) def has_delete_permission(self, request, obj=None): diff --git a/timed/redmine/admin.py b/timed/redmine/admin.py index d70465b33..a9e1a44f4 100644 --- a/timed/redmine/admin.py +++ b/timed/redmine/admin.py @@ -1,21 +1,7 @@ from django.contrib import admin -from timed.projects.admin import ProjectAdmin -from timed.projects.models import Project -from timed_adfinis.redmine.models import RedmineProject -from timed_adfinis.subscription.admin import SubscriptionProjectInline - -admin.site.unregister(Project) +from timed.redmine.models import RedmineProject class RedmineProjectInline(admin.StackedInline): model = RedmineProject - - -@admin.register(Project) -class ProjectAdmin(ProjectAdmin): - """Adfinis specific project including Redmine issue configuration.""" - - inlines = ProjectAdmin.inlines + [ - RedmineProjectInline, SubscriptionProjectInline - ] diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 2d5d0a98b..58fc0ac41 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -2,8 +2,8 @@ from django.core.management import call_command from redminelib.exceptions import ResourceNotFoundError +from timed.redmine.models import RedmineProject from timed.tracking.factories import ReportFactory -from timed_adfinis.redmine.models import RedmineProject @pytest.mark.freeze_time @@ -33,7 +33,7 @@ def test_redmine_report(db, freezer, mocker): redmine_instance.issue.get.assert_called_once_with(1000) assert issue.custom_fields == [{ - 'id': 6, + 'id': 0, 'value': report_hours }] assert 'Total hours: {0}'.format(report_hours) in issue.notes diff --git a/timed/settings.py b/timed/settings.py index 2eda08f37..aa6e07481 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -66,6 +66,8 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'timed.projects', 'timed.tracking', 'timed.reports', + 'timed.redmine', + 'timed.subscription', ] MIDDLEWARE_CLASSES = [ @@ -262,3 +264,14 @@ def parse_admins(admins): ADMINS = parse_admins(env.list('DJANGO_ADMINS', default=[])) + + +# Redmine definition (optional) + +REDMINE_URL = env.str('DJANGO_REDMINE_URL', default='') +REDMINE_APIKEY = env.str('DJANGO_REDMINE_APIKEY', default='') +REDMINE_HTACCESS_USER = env.str('DJANGO_REDMINE_HTACCESS_USER', default='') +REDMINE_HTACCESS_PASSWORD = env.str( + 'DJANGO_REDMINE_HTACCESS_PASSWORD', default='') +REDMINE_SPENTHOURS_FIELD = env.int( + 'DJANGO_REDMINE_SPENTHOURS_FIELD', default=0) diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py index d784a18ee..c2db01497 100644 --- a/timed/subscription/admin.py +++ b/timed/subscription/admin.py @@ -3,13 +3,8 @@ from django import forms from django.contrib import admin -from timed.projects.admin import CustomerAdmin -from timed.projects.models import Customer - from . import models -admin.site.unregister(Customer) - @admin.register(models.Subscription) class SubscriptionAdmin(admin.ModelAdmin): @@ -41,10 +36,3 @@ def save(self, commit=True): class CustomerPasswordInline(admin.StackedInline): form = CustomerPasswordForm model = models.CustomerPassword - - -@admin.register(Customer) -class CustomerAdmin(CustomerAdmin): - inlines = CustomerAdmin.inlines + [ - CustomerPasswordInline - ] From 14c40b1f650d04b3448b27c9fb1fa2e4cff2ac6e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 26 Jul 2017 10:26:51 +0200 Subject: [PATCH 234/980] Added bare api endpoint for work report --- timed/reports/tests/test_work_report.py | 12 +++++++++++ timed/reports/urls.py | 10 +++++++++ timed/reports/views.py | 28 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 timed/reports/tests/test_work_report.py create mode 100644 timed/reports/urls.py create mode 100644 timed/reports/views.py diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py new file mode 100644 index 000000000..75c03a303 --- /dev/null +++ b/timed/reports/tests/test_work_report.py @@ -0,0 +1,12 @@ +from django.core.urlresolvers import reverse +from rest_framework.status import HTTP_200_OK + +from timed.tracking.factories import ReportFactory + + +def test_work_report(auth_client): + ReportFactory.create_batch(10) + + url = reverse('work-reports-list') + res = auth_client.get(url) + assert res.status_code == HTTP_200_OK diff --git a/timed/reports/urls.py b/timed/reports/urls.py new file mode 100644 index 000000000..a46a8d2ea --- /dev/null +++ b/timed/reports/urls.py @@ -0,0 +1,10 @@ +from django.conf import settings +from rest_framework.routers import DefaultRouter + +from timed_adfinis.reporting import views + +r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) + +r.register(r'work-report', views.WorkReport, 'work-reports') + +urlpatterns = r.urls diff --git a/timed/reports/views.py b/timed/reports/views.py new file mode 100644 index 000000000..c52df5038 --- /dev/null +++ b/timed/reports/views.py @@ -0,0 +1,28 @@ +from django.http import HttpResponse +from rest_framework.viewsets import GenericViewSet + +from timed.tracking.filters import ReportFilterSet +from timed.tracking.models import Report + + +class WorkReport(GenericViewSet): + """ + Build a ods work report of reports with given filters. + + Work report is Adfinis specific and is used for Administration + to send invoice to customer. + """ + + filter_class = ReportFilterSet + ordering_fields = ('-date', ) + + def get_queryset(self): + return Report.objects.select_related( + 'task', + 'user', + 'activity' + ) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + return HttpResponse(queryset.count()) From ac23c1cfa0929c3d2928bfbdc65c339a0a17c230 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 26 Jul 2017 15:57:00 +0200 Subject: [PATCH 235/980] New end point providing work report export --- timed/reports/tests/test_work_report.py | 25 +++++++- timed/reports/views.py | 81 +++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 75c03a303..1e1ed7960 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -1,12 +1,31 @@ +import io + +import ezodf from django.core.urlresolvers import reverse -from rest_framework.status import HTTP_200_OK +from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST from timed.tracking.factories import ReportFactory def test_work_report(auth_client): - ReportFactory.create_batch(10) + user = auth_client.user + ReportFactory.create_batch(10, user=user) url = reverse('work-reports-list') - res = auth_client.get(url) + res = auth_client.get(url, data={ + 'user': auth_client.user.id + }) assert res.status_code == HTTP_200_OK + + content = io.BytesIO(res.content) + doc = ezodf.opendoc(content) + table = doc.sheets[0] + assert table['B9'].value == 'Test User' + + +def test_work_report_empty(auth_client): + url = reverse('work-reports-list') + res = auth_client.get(url, data={ + 'user': auth_client.user.id + }) + assert res.status_code == HTTP_400_BAD_REQUEST diff --git a/timed/reports/views.py b/timed/reports/views.py index c52df5038..58cd3b953 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -1,6 +1,12 @@ -from django.http import HttpResponse +from datetime import date +from io import BytesIO + +import ezodf +from django.http import HttpResponse, HttpResponseBadRequest +from pkg_resources import resource_string from rest_framework.viewsets import GenericViewSet +from timed.projects.models import Customer, Project from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report @@ -14,15 +20,80 @@ class WorkReport(GenericViewSet): """ filter_class = ReportFilterSet - ordering_fields = ('-date', ) + ordering = ('-date', ) def get_queryset(self): return Report.objects.select_related( - 'task', 'user', - 'activity' ) + def _parse_query_params(self, queryset, request): + """Parse query params by using filter_class.""" + fltr = self.filter_class( + request.query_params, + queryset=queryset, + request=request) + form = fltr.form + form.is_valid() + return form.cleaned_data + + def _format_user_name(self, user): + # in the long run this function should move onto User in timed + first_name = user.first_name or '' + last_name = user.last_name or '' + + return '{0} {1}'.format(first_name, last_name).strip() + def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) - return HttpResponse(queryset.count()) + count = queryset.count() + if count == 0: + return HttpResponseBadRequest() + params = self._parse_query_params(queryset, request) + + customer = Customer.objects.filter(id=params.get('customer')).first() + project = Project.objects.filter(id=params.get('project')).first() + from_date = params.get('from_date') + to_date = params.get('to_date') + + tmpl = resource_string('timed_adfinis.reporting', 'workreport.ots') + doc = ezodf.opendoc(BytesIO(tmpl)) + table = doc.sheets[0] + date_style = table['B5'].style_name + # in template cell C3 is empty but styled as needed for float + float_style = table['C3'].style_name + + def _set_value(cell, value, style_name=None, value_type=None): + if value is not None: + table[cell].set_value(value, value_type=value_type) + if style_name is not None: + table[cell].style_name = date_style + + # header values + _set_value('B3', customer and customer.name) + _set_value('B4', project and project.name) + _set_value('B5', from_date, date_style, 'date') + _set_value('B6', to_date, date_style, 'date') + _set_value('B8', date.today(), date_style, 'date') + _set_value('B9', self._format_user_name(request.user)) + + # for simplicity insert reports in reverse order + for report in queryset: + table.insert_rows(12, 1) + _set_value('A13', report.date, date_style, 'date') + + hours = report.duration.total_seconds() / 60 / 60 + _set_value('B13', hours, float_style) + + _set_value('C13', self._format_user_name(report.user)) + _set_value('D13', report.comment) + + # calculate location of total hours as insert rows moved it + table[13 + count, 2].formula = 'of:=SUM(B13:B{0})'.format( + str(13 + count - 1)) + + response = HttpResponse( + doc.tobytes(), + content_type='application/vnd.oasis.opendocument.spreadsheet') + response['Content-Disposition'] = 'attachment; filename=workreport.ods' + return response From 9a76cb5d3be5cb00bf57e75fe542a266ef4a1d02 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 4 Aug 2017 11:44:21 +0200 Subject: [PATCH 236/980] Remove obsolete _format_user_name method in WorkReport --- timed/reports/views.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index 58cd3b953..b968e70b1 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -37,13 +37,6 @@ def _parse_query_params(self, queryset, request): form.is_valid() return form.cleaned_data - def _format_user_name(self, user): - # in the long run this function should move onto User in timed - first_name = user.first_name or '' - last_name = user.last_name or '' - - return '{0} {1}'.format(first_name, last_name).strip() - def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) count = queryset.count() @@ -75,7 +68,7 @@ def _set_value(cell, value, style_name=None, value_type=None): _set_value('B5', from_date, date_style, 'date') _set_value('B6', to_date, date_style, 'date') _set_value('B8', date.today(), date_style, 'date') - _set_value('B9', self._format_user_name(request.user)) + _set_value('B9', request.user.get_full_name()) # for simplicity insert reports in reverse order for report in queryset: @@ -85,7 +78,7 @@ def _set_value(cell, value, style_name=None, value_type=None): hours = report.duration.total_seconds() / 60 / 60 _set_value('B13', hours, float_style) - _set_value('C13', self._format_user_name(report.user)) + _set_value('C13', request.user.get_full_name()) _set_value('D13', report.comment) # calculate location of total hours as insert rows moved it From 70b88289d31c1914d79ae3b3bbb78987c3c57cd5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 10 Aug 2017 17:32:39 +0200 Subject: [PATCH 237/980] User of report needs to be in work report --- timed/reports/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index b968e70b1..4582afb5d 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -78,7 +78,7 @@ def _set_value(cell, value, style_name=None, value_type=None): hours = report.duration.total_seconds() / 60 / 60 _set_value('B13', hours, float_style) - _set_value('C13', request.user.get_full_name()) + _set_value('C13', report.user.get_full_name()) _set_value('D13', report.comment) # calculate location of total hours as insert rows moved it From 2de83b16c17ce545b541504c36556181644ec399 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 15 Aug 2017 12:51:39 +0200 Subject: [PATCH 238/980] Properly format work report refs #15653 --- timed/reports/views.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index 4582afb5d..59f11b038 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -1,8 +1,8 @@ from datetime import date from io import BytesIO -import ezodf from django.http import HttpResponse, HttpResponseBadRequest +from ezodf import Cell, opendoc from pkg_resources import resource_string from rest_framework.viewsets import GenericViewSet @@ -48,38 +48,37 @@ def list(self, request, *args, **kwargs): project = Project.objects.filter(id=params.get('project')).first() from_date = params.get('from_date') to_date = params.get('to_date') + today = date.today() tmpl = resource_string('timed_adfinis.reporting', 'workreport.ots') - doc = ezodf.opendoc(BytesIO(tmpl)) + doc = opendoc(BytesIO(tmpl)) table = doc.sheets[0] date_style = table['B5'].style_name # in template cell C3 is empty but styled as needed for float float_style = table['C3'].style_name - - def _set_value(cell, value, style_name=None, value_type=None): - if value is not None: - table[cell].set_value(value, value_type=value_type) - if style_name is not None: - table[cell].style_name = date_style + # in template cell C4 is empty but styled as needed for text with wrap + text_style = table['C4'].style_name # header values - _set_value('B3', customer and customer.name) - _set_value('B4', project and project.name) - _set_value('B5', from_date, date_style, 'date') - _set_value('B6', to_date, date_style, 'date') - _set_value('B8', date.today(), date_style, 'date') - _set_value('B9', request.user.get_full_name()) + table['B3'] = Cell(customer and customer.name) + table['B3'] = Cell(customer and customer.name) + table['B4'] = Cell(project and project.name) + table['B5'] = Cell(from_date, style_name=date_style, value_type='date') + table['B6'] = Cell(to_date, style_name=date_style, value_type='date') + table['B8'] = Cell(today, style_name=date_style, value_type='date') + table['B9'] = Cell(request.user.get_full_name()) # for simplicity insert reports in reverse order for report in queryset: table.insert_rows(12, 1) - _set_value('A13', report.date, date_style, 'date') + table['A13'] = Cell(report.date, style_name=date_style, + value_type='date') hours = report.duration.total_seconds() / 60 / 60 - _set_value('B13', hours, float_style) + table['B13'] = Cell(hours, style_name=float_style) - _set_value('C13', report.user.get_full_name()) - _set_value('D13', report.comment) + table['C13'] = Cell(report.user.get_full_name()) + table['D13'] = Cell(report.comment, style_name=text_style) # calculate location of total hours as insert rows moved it table[13 + count, 2].formula = 'of:=SUM(B13:B{0})'.format( From 821bbebb3896614e042328d25966a8f39d4079e2 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 17 Aug 2017 09:59:52 +0200 Subject: [PATCH 239/980] Create one work report per project when filters result in several projects zip different work reports. --- timed/reports/tests/test_work_report.py | 61 ++++++++++- timed/reports/views.py | 128 ++++++++++++++++++------ 2 files changed, 158 insertions(+), 31 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 1e1ed7960..f5c729faf 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -1,28 +1,83 @@ import io +from datetime import date +from zipfile import ZipFile import ezodf +import pytest from django.core.urlresolvers import reverse from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST +from timed.projects.factories import (CustomerFactory, ProjectFactory, + TaskFactory) from timed.tracking.factories import ReportFactory -def test_work_report(auth_client): +@pytest.mark.freeze_time('2017-09-01') +def test_work_report_single_project(auth_client): user = auth_client.user - ReportFactory.create_batch(10, user=user) + # spaces should be replaced with underscore + customer = CustomerFactory.create(name='Customer Name') + # slashes should be dropped from file name + project = ProjectFactory.create(customer=customer, name='Project/') + task = TaskFactory.create(project=project) + ReportFactory.create_batch( + 10, user=user, task=task, date=date(2017, 8, 17) + ) url = reverse('work-reports-list') res = auth_client.get(url, data={ - 'user': auth_client.user.id + 'user': auth_client.user.id, + 'from_date': '2017-08-01', + 'to_date': '2017-08-31', }) assert res.status_code == HTTP_200_OK + assert '1708-20170901-Customer_Name-Project.ods' in ( + res['Content-Disposition'] + ) content = io.BytesIO(res.content) doc = ezodf.opendoc(content) table = doc.sheets[0] + assert table['B5'].value == '2017-08-01' + assert table['B6'].value == '2017-08-31' assert table['B9'].value == 'Test User' +@pytest.mark.freeze_time('2017-09-01') +def test_work_report_multiple_projects(auth_client): + NUM_PROJECTS = 2 + + user = auth_client.user + customer = CustomerFactory.create(name='Customer') + report_date = date(2017, 8, 17) + for i in range(NUM_PROJECTS): + project = ProjectFactory.create( + customer=customer, name='Project{0}'.format(i) + ) + task = TaskFactory.create(project=project) + ReportFactory.create_batch(10, user=user, task=task, date=report_date) + + url = reverse('work-reports-list') + res = auth_client.get(url, data={ + 'user': auth_client.user.id + }) + assert res.status_code == HTTP_200_OK + assert '20170901-WorkReports.zip' in ( + res['Content-Disposition'] + ) + + content = io.BytesIO(res.content) + with ZipFile(content, 'r') as zipfile: + for i in range(NUM_PROJECTS): + ods_content = zipfile.read( + '1708-20170901-Customer-Project{0}.ods'.format(i) + ) + doc = ezodf.opendoc(io.BytesIO(ods_content)) + table = doc.sheets[0] + assert table['B5'].value == '2017-08-17' + assert table['B6'].value == '2017-08-17' + + def test_work_report_empty(auth_client): url = reverse('work-reports-list') res = auth_client.get(url, data={ diff --git a/timed/reports/views.py b/timed/reports/views.py index 59f11b038..4f9f0df8b 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -1,12 +1,14 @@ +import re +from collections import defaultdict from datetime import date from io import BytesIO +from zipfile import ZipFile from django.http import HttpResponse, HttpResponseBadRequest from ezodf import Cell, opendoc from pkg_resources import resource_string from rest_framework.viewsets import GenericViewSet -from timed.projects.models import Customer, Project from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report @@ -15,6 +17,9 @@ class WorkReport(GenericViewSet): """ Build a ods work report of reports with given filters. + It creates one work report per project. If given filters results + in several projects work reports will be returned as zip. + Work report is Adfinis specific and is used for Administration to send invoice to customer. """ @@ -37,18 +42,40 @@ def _parse_query_params(self, queryset, request): form.is_valid() return form.cleaned_data - def list(self, request, *args, **kwargs): - queryset = self.filter_queryset(self.get_queryset()) - count = queryset.count() - if count == 0: - return HttpResponseBadRequest() - params = self._parse_query_params(queryset, request) + def _clean_filename(self, name): + """ + Clean name so it can be used in file paths. + + To accomplish this it will remove all special chars and + replace spaces with underscores + """ + escaped = re.sub('[^\w\s-]', '', name) + return re.sub('\s+', '_', escaped) + + def _generate_workreport_name(self, from_date, today, project): + """ + Generate workreport name. + + Name is in format: YYMM-YYYYMMDD-$Customer-$Project.ods + whereas YYMM is year and month of from_date and YYYYMMDD + is date when work reports gets created. + """ + return '{0}-{1}-{2}-{3}.ods'.format( + from_date.strftime('%y%m'), + today.strftime('%Y%m%d'), + self._clean_filename(project.customer.name), + self._clean_filename(project.name) + ) - customer = Customer.objects.filter(id=params.get('customer')).first() - project = Project.objects.filter(id=params.get('project')).first() - from_date = params.get('from_date') - to_date = params.get('to_date') - today = date.today() + def _create_workreport(self, from_date, to_date, today, project, reports, + user): + """ + Create ods workreport. + + :rtype: tuple + :return: tuple where as first value is name and second ezodf document + """ + customer = project.customer tmpl = resource_string('timed_adfinis.reporting', 'workreport.ots') doc = opendoc(BytesIO(tmpl)) @@ -59,17 +86,8 @@ def list(self, request, *args, **kwargs): # in template cell C4 is empty but styled as needed for text with wrap text_style = table['C4'].style_name - # header values - table['B3'] = Cell(customer and customer.name) - table['B3'] = Cell(customer and customer.name) - table['B4'] = Cell(project and project.name) - table['B5'] = Cell(from_date, style_name=date_style, value_type='date') - table['B6'] = Cell(to_date, style_name=date_style, value_type='date') - table['B8'] = Cell(today, style_name=date_style, value_type='date') - table['B9'] = Cell(request.user.get_full_name()) - # for simplicity insert reports in reverse order - for report in queryset: + for report in reports: table.insert_rows(12, 1) table['A13'] = Cell(report.date, style_name=date_style, value_type='date') @@ -80,12 +98,66 @@ def list(self, request, *args, **kwargs): table['C13'] = Cell(report.user.get_full_name()) table['D13'] = Cell(report.comment, style_name=text_style) + # when from and to date are None find lowest and biggest date + from_date = min(report.date, from_date or date.max) + to_date = max(report.date, to_date or date.min) + + # header values + table['B3'] = Cell(customer and customer.name) + table['B4'] = Cell(project and project.name) + table['B5'] = Cell(from_date, style_name=date_style, value_type='date') + table['B6'] = Cell(to_date, style_name=date_style, value_type='date') + table['B8'] = Cell(today, style_name=date_style, value_type='date') + table['B9'] = Cell(user.get_full_name()) + # calculate location of total hours as insert rows moved it - table[13 + count, 2].formula = 'of:=SUM(B13:B{0})'.format( - str(13 + count - 1)) + table[13 + len(reports), 2].formula = 'of:=SUM(B13:B{0})'.format( + str(13 + len(reports) - 1)) + + name = self._generate_workreport_name(from_date, today, project) + return (name, doc) - response = HttpResponse( - doc.tobytes(), - content_type='application/vnd.oasis.opendocument.spreadsheet') - response['Content-Disposition'] = 'attachment; filename=workreport.ods' + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + count = queryset.count() + if count == 0: + return HttpResponseBadRequest() + params = self._parse_query_params(queryset, request) + + from_date = params.get('from_date') + to_date = params.get('to_date') + today = date.today() + + reports_by_project = defaultdict(list) + for report in queryset: + reports_by_project[report.task.project].append(report) + + docs = [ + self._create_workreport( + from_date, to_date, today, project, reports, request.user + ) + for project, reports in reports_by_project.items() + ] + + if len(docs) == 1: + name, doc = docs[0] + response = HttpResponse( + doc.tobytes(), + content_type='application/vnd.oasis.opendocument.spreadsheet') + response['Content-Disposition'] = ( + 'attachment; filename=%s' % name + ) + return response + + # zip multiple work reports + buf = BytesIO() + with ZipFile(buf, 'w') as zf: + for name, doc in docs: + zf.writestr(name, doc.tobytes()) + response = HttpResponse(buf.getvalue(), content_type='application/zip') + response['Content-Disposition'] = ( + 'attachment; filename=%s-WorkReports.zip' % ( + today.strftime('%Y%m%d') + ) + ) return response From da02765e89194ad8ed589eb393a16894f45c3152 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Aug 2017 13:55:26 +0200 Subject: [PATCH 240/980] Add thorough test for generate work report name --- timed/reports/tests/test_work_report.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index f5c729faf..c21c7ec17 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -10,6 +10,7 @@ from timed.projects.factories import (CustomerFactory, ProjectFactory, TaskFactory) from timed.tracking.factories import ReportFactory +from timed_adfinis.reporting.views import WorkReport @pytest.mark.freeze_time('2017-09-01') @@ -84,3 +85,21 @@ def test_work_report_empty(auth_client): 'user': auth_client.user.id }) assert res.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.parametrize('customer_name,project_name,expected', [ + ('Customer Name', 'Project/', '1708-20170818-Customer_Name-Project.ods'), + ('Customer-Name', 'Project', '1708-20170818-Customer-Name-Project.ods'), + ('Customer$Name', 'Project', '1708-20170818-CustomerName-Project.ods'), +]) +def test_generate_work_report_name(db, customer_name, project_name, expected): + test_date = date(2017, 8, 18) + view = WorkReport() + + # spaces should be replaced with underscore + customer = CustomerFactory.create(name=customer_name) + # slashes should be dropped from file name + project = ProjectFactory.create(customer=customer, name=project_name) + + name = view._generate_workreport_name(test_date, test_date, project) + assert name == expected From 21342f52cb0892308a257a6be28fca19414c62bb Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Aug 2017 16:16:35 +0200 Subject: [PATCH 241/980] Support ordering filters in work report This fixes an issue that lead to a 500 error when another filter than date has been set. --- timed/reports/tests/test_work_report.py | 4 ++-- timed/reports/urls.py | 2 +- timed/reports/views.py | 8 ++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index c21c7ec17..4461f8fd0 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -10,7 +10,7 @@ from timed.projects.factories import (CustomerFactory, ProjectFactory, TaskFactory) from timed.tracking.factories import ReportFactory -from timed_adfinis.reporting.views import WorkReport +from timed_adfinis.reporting.views import WorkReportViewSet @pytest.mark.freeze_time('2017-09-01') @@ -94,7 +94,7 @@ def test_work_report_empty(auth_client): ]) def test_generate_work_report_name(db, customer_name, project_name, expected): test_date = date(2017, 8, 18) - view = WorkReport() + view = WorkReportViewSet() # spaces should be replaced with underscore customer = CustomerFactory.create(name=customer_name) diff --git a/timed/reports/urls.py b/timed/reports/urls.py index a46a8d2ea..bdebffd13 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -5,6 +5,6 @@ r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'work-report', views.WorkReport, 'work-reports') +r.register(r'work-report', views.WorkReportViewSet, 'work-reports') urlpatterns = r.urls diff --git a/timed/reports/views.py b/timed/reports/views.py index 4f9f0df8b..4a1c2a7e9 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -11,9 +11,10 @@ from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report +from timed.tracking.views import ReportViewSet -class WorkReport(GenericViewSet): +class WorkReportViewSet(GenericViewSet): """ Build a ods work report of reports with given filters. @@ -25,7 +26,8 @@ class WorkReport(GenericViewSet): """ filter_class = ReportFilterSet - ordering = ('-date', ) + ordering = ReportViewSet.ordering + ordering_fields = ReportViewSet.ordering_fields def get_queryset(self): return Report.objects.select_related( @@ -119,6 +121,8 @@ def _create_workreport(self, from_date, to_date, today, project, reports, def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) + # needed as we add items in reverse order to work report + queryset = queryset.reverse() count = queryset.count() if count == 0: return HttpResponseBadRequest() From a301450398fbd60d2825985e5de5b4de3be88448 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 29 Aug 2017 16:18:39 +0200 Subject: [PATCH 242/980] Adjusted work template styling see spezification refs #15789 --- timed/reports/tests/test_work_report.py | 10 +++---- timed/reports/views.py | 35 ++++++++++++++++--------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 4461f8fd0..9d9ae6124 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -39,9 +39,9 @@ def test_work_report_single_project(auth_client): content = io.BytesIO(res.content) doc = ezodf.opendoc(content) table = doc.sheets[0] - assert table['B5'].value == '2017-08-01' - assert table['B6'].value == '2017-08-31' - assert table['B9'].value == 'Test User' + assert table['C5'].value == '2017-08-01' + assert table['C6'].value == '2017-08-31' + assert table['C9'].value == 'Test User' @pytest.mark.freeze_time('2017-09-01') @@ -75,8 +75,8 @@ def test_work_report_multiple_projects(auth_client): ) doc = ezodf.opendoc(io.BytesIO(ods_content)) table = doc.sheets[0] - assert table['B5'].value == '2017-08-17' - assert table['B6'].value == '2017-08-17' + assert table['C5'].value == '2017-08-17' + assert table['C6'].value == '2017-08-17' def test_work_report_empty(auth_client): diff --git a/timed/reports/views.py b/timed/reports/views.py index 4a1c2a7e9..6f730e7ad 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -82,22 +82,26 @@ def _create_workreport(self, from_date, to_date, today, project, reports, tmpl = resource_string('timed_adfinis.reporting', 'workreport.ots') doc = opendoc(BytesIO(tmpl)) table = doc.sheets[0] - date_style = table['B5'].style_name - # in template cell C3 is empty but styled as needed for float - float_style = table['C3'].style_name - # in template cell C4 is empty but styled as needed for text with wrap - text_style = table['C4'].style_name + date_style = table['C5'].style_name + # in template cell D3 is empty but styled for float and borders + float_style = table['D3'].style_name + # in template cell D4 is empty but styled for text wrap and borders + text_style = table['D4'].style_name + # in template cell D8 is empty but styled for date with borders + date_style_report = table['D8'].style_name # for simplicity insert reports in reverse order for report in reports: table.insert_rows(12, 1) - table['A13'] = Cell(report.date, style_name=date_style, + table['A13'] = Cell(report.date, + style_name=date_style_report, value_type='date') hours = report.duration.total_seconds() / 60 / 60 table['B13'] = Cell(hours, style_name=float_style) - table['C13'] = Cell(report.user.get_full_name()) + table['C13'] = Cell(report.user.get_full_name(), + style_name=text_style) table['D13'] = Cell(report.comment, style_name=text_style) # when from and to date are None find lowest and biggest date @@ -105,12 +109,17 @@ def _create_workreport(self, from_date, to_date, today, project, reports, to_date = max(report.date, to_date or date.min) # header values - table['B3'] = Cell(customer and customer.name) - table['B4'] = Cell(project and project.name) - table['B5'] = Cell(from_date, style_name=date_style, value_type='date') - table['B6'] = Cell(to_date, style_name=date_style, value_type='date') - table['B8'] = Cell(today, style_name=date_style, value_type='date') - table['B9'] = Cell(user.get_full_name()) + table['C3'] = Cell(customer and customer.name) + table['C4'] = Cell(project and project.name) + table['C5'] = Cell(from_date, style_name=date_style, value_type='date') + table['C6'] = Cell(to_date, style_name=date_style, value_type='date') + table['C8'] = Cell(today, style_name=date_style, value_type='date') + table['C9'] = Cell(user.get_full_name()) + + # reset temporary styles (mainly because of borders) + table['D3'].style_name = '' + table['D4'].style_name = '' + table['D8'].style_name = '' # calculate location of total hours as insert rows moved it table[13 + len(reports), 2].formula = 'of:=SUM(B13:B{0})'.format( From 30a9321122239e60c51e65cb2e6b1c4b9be6c7ad Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 11 Oct 2017 17:28:37 +0200 Subject: [PATCH 243/980] Assign reviewers to work report --- timed/reports/tests/test_work_report.py | 3 ++- timed/reports/views.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 9d9ae6124..1fbf9ca74 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -22,7 +22,7 @@ def test_work_report_single_project(auth_client): project = ProjectFactory.create(customer=customer, name='Project/') task = TaskFactory.create(project=project) ReportFactory.create_batch( - 10, user=user, task=task, date=date(2017, 8, 17) + 10, user=user, verified_by=user, task=task, date=date(2017, 8, 17) ) url = reverse('work-reports-list') @@ -42,6 +42,7 @@ def test_work_report_single_project(auth_client): assert table['C5'].value == '2017-08-01' assert table['C6'].value == '2017-08-31' assert table['C9'].value == 'Test User' + assert table['C10'].value == 'Test User' @pytest.mark.freeze_time('2017-09-01') diff --git a/timed/reports/views.py b/timed/reports/views.py index 6f730e7ad..6a4b520b4 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -31,7 +31,7 @@ class WorkReportViewSet(GenericViewSet): def get_queryset(self): return Report.objects.select_related( - 'user', + 'user', 'verified_by' ) def _parse_query_params(self, queryset, request): @@ -78,6 +78,10 @@ def _create_workreport(self, from_date, to_date, today, project, reports, :return: tuple where as first value is name and second ezodf document """ customer = project.customer + verifiers = sorted({ + report.verified_by.get_full_name() + for report in reports if report.verified_by_id is not None + }) tmpl = resource_string('timed_adfinis.reporting', 'workreport.ots') doc = opendoc(BytesIO(tmpl)) @@ -115,6 +119,7 @@ def _create_workreport(self, from_date, to_date, today, project, reports, table['C6'] = Cell(to_date, style_name=date_style, value_type='date') table['C8'] = Cell(today, style_name=date_style, value_type='date') table['C9'] = Cell(user.get_full_name()) + table['C10'] = Cell(', '.join(verifiers)) # reset temporary styles (mainly because of borders) table['D3'].style_name = '' From 9ebec702de979355c7c9fc1646e4c40b9a472763 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 15:32:56 +0200 Subject: [PATCH 244/980] Integrate work report --- setup.py | 1 + timed/reports/tests/test_work_report.py | 2 +- timed/reports/urls.py | 2 +- timed/reports/views.py | 5 +---- timed/urls.py | 3 ++- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index dc77ffade..e2e59547c 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ def find_data(packages, extensions): 'rest_condition==1.0.3', 'django-money==0.11.4', 'python-redmine==2.0.2', + 'ezodf==0.3.2' ), keywords='timetracking', url='https://adfinis-sygroup.ch/', diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 1fbf9ca74..33c7217b4 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -9,8 +9,8 @@ from timed.projects.factories import (CustomerFactory, ProjectFactory, TaskFactory) +from timed.reports.views import WorkReportViewSet from timed.tracking.factories import ReportFactory -from timed_adfinis.reporting.views import WorkReportViewSet @pytest.mark.freeze_time('2017-09-01') diff --git a/timed/reports/urls.py b/timed/reports/urls.py index bdebffd13..6a8ac910f 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -1,7 +1,7 @@ from django.conf import settings from rest_framework.routers import DefaultRouter -from timed_adfinis.reporting import views +from . import views r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) diff --git a/timed/reports/views.py b/timed/reports/views.py index 6a4b520b4..61a6d8ade 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -20,9 +20,6 @@ class WorkReportViewSet(GenericViewSet): It creates one work report per project. If given filters results in several projects work reports will be returned as zip. - - Work report is Adfinis specific and is used for Administration - to send invoice to customer. """ filter_class = ReportFilterSet @@ -83,7 +80,7 @@ def _create_workreport(self, from_date, to_date, today, project, reports, for report in reports if report.verified_by_id is not None }) - tmpl = resource_string('timed_adfinis.reporting', 'workreport.ots') + tmpl = resource_string('timed.reports', 'workreport.ots') doc = opendoc(BytesIO(tmpl)) table = doc.sheets[0] date_style = table['C5'].style_name diff --git a/timed/urls.py b/timed/urls.py index cb15be2d9..2817294ea 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -10,5 +10,6 @@ url(r'^api/v1/auth/refresh', refresh_jwt_token, name='refresh'), url(r'^api/v1/', include('timed.employment.urls')), url(r'^api/v1/', include('timed.projects.urls')), - url(r'^api/v1/', include('timed.tracking.urls')) + url(r'^api/v1/', include('timed.tracking.urls')), + url(r'^api/v1/', include('timed.reports.urls')) ] From 1c80f939f5e17c91244ff4f1d3501d8008d16a40 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 15:42:02 +0200 Subject: [PATCH 245/980] Add option to provide work report template --- timed/reports/views.py | 6 +++--- timed/settings.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index 61a6d8ade..4c80c1a87 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -4,9 +4,9 @@ from io import BytesIO from zipfile import ZipFile +from django.conf import settings from django.http import HttpResponse, HttpResponseBadRequest from ezodf import Cell, opendoc -from pkg_resources import resource_string from rest_framework.viewsets import GenericViewSet from timed.tracking.filters import ReportFilterSet @@ -80,8 +80,8 @@ def _create_workreport(self, from_date, to_date, today, project, reports, for report in reports if report.verified_by_id is not None }) - tmpl = resource_string('timed.reports', 'workreport.ots') - doc = opendoc(BytesIO(tmpl)) + tmpl = settings.WORK_REPORT_PATH + doc = opendoc(tmpl) table = doc.sheets[0] date_style = table['C5'].style_name # in template cell D3 is empty but styled for float and borders diff --git a/timed/settings.py b/timed/settings.py index 1959df29c..c9f701fc4 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -3,6 +3,7 @@ import re import environ +from pkg_resources import resource_filename env = environ.Env() @@ -280,3 +281,11 @@ def parse_admins(admins): 'DJANGO_REDMINE_HTACCESS_PASSWORD', default='') REDMINE_SPENTHOURS_FIELD = env.int( 'DJANGO_REDMINE_SPENTHOURS_FIELD', default=0) + + +# Work report definition + +WORK_REPORT_PATH = env.str( + 'DJANGO_WORK_REPORT_PATH', + default=resource_filename('timed.reports', 'workreport.ots') +) From 83f19598e00ac4d4f56ccd98767868376bac8eda Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Oct 2017 10:47:35 +0200 Subject: [PATCH 246/980] Make last name of user required This is needed as frontend relies on full name to exist. First name is not required as last name may just be an name of organization. --- .../migrations/0008_auto_20171013_1041.py | 20 +++++++++++++++++++ timed/employment/models.py | 7 +++++++ 2 files changed, 27 insertions(+) create mode 100644 timed/employment/migrations/0008_auto_20171013_1041.py diff --git a/timed/employment/migrations/0008_auto_20171013_1041.py b/timed/employment/migrations/0008_auto_20171013_1041.py new file mode 100644 index 000000000..b7b8569cc --- /dev/null +++ b/timed/employment/migrations/0008_auto_20171013_1041.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-13 08:41 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0007_auto_20170911_0959'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='last_name', + field=models.CharField(max_length=30, verbose_name='last name'), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index fbb186659..4ad940b7c 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -8,6 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Sum, functions +from django.utils.translation import ugettext_lazy as _ from timed.models import WeekdaysField @@ -398,6 +399,12 @@ class User(AbstractUser): Indicate whether user has finished tour through Timed in frontend. """ + last_name = models.CharField(_('last name'), max_length=30, blank=False) + """ + Overwrite last name to make it required as interface relies on it. + May also be name of organization if need to. + """ + objects = UserManager() def calculate_worktime(self, start, end): From e9dc5eae8214985a4368b7f54894dde23f78d354 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Oct 2017 11:36:19 +0200 Subject: [PATCH 247/980] Add missing workreport template --- timed/reports/workreport.ots | Bin 0 -> 12482 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 timed/reports/workreport.ots diff --git a/timed/reports/workreport.ots b/timed/reports/workreport.ots new file mode 100644 index 0000000000000000000000000000000000000000..ae0ed5f160628d4ddf14faf132bf47f4930edbec GIT binary patch literal 12482 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 Date: Fri, 13 Oct 2017 11:36:39 +0200 Subject: [PATCH 248/980] Update to pyexcel-ezodf which supports opendoc with filenames --- setup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index e2e59547c..6847325f2 100644 --- a/setup.py +++ b/setup.py @@ -53,15 +53,15 @@ def find_data(packages, extensions): 'psycopg2>=2.7,<2.8', 'pytz==2017.2', 'pyexcel-webio==0.1.2', - 'pyexcel-io==0.4.2', + 'pyexcel-io==0.5.1', 'django-excel==0.0.9', - 'pyexcel-ods3==0.4.0', - 'pyexcel-xlsx==0.4.1', + 'pyexcel-ods3==0.5.0', + 'pyexcel-xlsx==0.5.0.1', + 'pyexcel-ezodf==0.3.3', 'django-environ==0.4.3', 'rest_condition==1.0.3', 'django-money==0.11.4', 'python-redmine==2.0.2', - 'ezodf==0.3.2' ), keywords='timetracking', url='https://adfinis-sygroup.ch/', From 79c0ade2610c115b88f716cada9a0e81c6b8f196 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Oct 2017 11:54:03 +0200 Subject: [PATCH 249/980] Make default settings helper work with bools DEBUG flag was mistakenly set to True on production. --- timed/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index c9f701fc4..fd589c78d 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -20,7 +20,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): """Environment aware default.""" - return ENV == 'prod' and default_prod or default_dev + return default_prod if ENV == 'prod' else default_dev # Database definition From c356e27f4118acf873b082487c66c02b2c5aec64 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Oct 2017 17:16:33 +0200 Subject: [PATCH 250/980] Add documentation of different settings --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index cd5134166..197615967 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,34 @@ $ ./manage.py createsuperuser # Create a new Django superuser You can now access the API at http://localhost:8000/api/v1 and the admin interface at http://localhost:8000/admin/ +## Configuration + +Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) +according to type. + +| Parameter | Description | Default | +| ----------------------------------- | ---------------------------------------------------------- | -------------------------------- | +| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | +| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | +| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | +| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | +| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | +| `DJANGO_DATABASE_NAME` | Database name | timed | +| `DJANGO_DATABASE_USER` | Database username | timed | +| `DJANGO_DATABASE_HOST` | Database hostname | localhost | +| `DJANGO_DATABASE_PORT` | Database port | 5432 | +| `DJANGO_AUTH_LDAP_ENABLED` | Enable LDAP authentication | False | +| `DJANGO_AUTH_LDAP_SERVER_URI` | uri of LDAP server | not set | +| `DJANGO_AUTH_LDAP_BIND_DN` | distinguished name to use when binding to LDAP server | not set | +| `DJANGO_AUTH_LDAP_PASSWORD` | password to use with DJANGO_AUTH_LDAP_BIND_DN | not set | +| `DJANGO_AUTH_LDAP_USER_DN_TEMPLATE` | template to distinguish user’s username | not set | +| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | +| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | +| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | +| `DJANGO_ADMINS` | List of people who get error notifications | not set | +| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | + + ## Testing Run tests by executing `make test` From b159c177ff61d6b3542803edd52ec68598886738 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 16 Oct 2017 10:03:47 +0200 Subject: [PATCH 251/980] Avoid exception when not estimated time is set on project --- .../management/commands/redmine_report.py | 5 ++++- timed/redmine/tests/test_redmine_report.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index e363dc676..6fa03b042 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -55,7 +55,10 @@ def handle(self, *args, **options): ).annotate(total_hours=Sum('tasks__reports__duration')) for project in projects: - estimated_hours = project.estimated_time.total_seconds() / 3600 + estimated_hours = ( + project.estimated_time and + project.estimated_time.total_seconds() / 3600 + ) total_hours = project.total_hours.total_seconds() / 3600 try: issue = redmine.issue.get(project.redmine_project.issue_id) diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 58fc0ac41..6183356e3 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -2,6 +2,7 @@ from django.core.management import call_command from redminelib.exceptions import ResourceNotFoundError +from timed.projects.factories import ProjectFactory, TaskFactory from timed.redmine.models import RedmineProject from timed.tracking.factories import ReportFactory @@ -46,6 +47,27 @@ def test_redmine_report(db, freezer, mocker): issue.save.assert_called_once_with() +@pytest.mark.freeze_time +def test_redmine_report_no_estimated_time(db, freezer, mocker): + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch('redminelib.Redmine') + redmine_class.return_value = redmine_instance + + freezer.move_to('2017-07-28') + project = ProjectFactory.create(estimated_time=None) + task = TaskFactory.create(project=project) + report = ReportFactory.create(comment='ADSY <=> Other', task=task) + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + freezer.move_to('2017-07-31') + call_command('redmine_report', options={'--last-days': '7'}) + + redmine_instance.issue.get.assert_called_once_with(1000) + issue.save.assert_called_once_with() + + @pytest.mark.freeze_time def test_redmine_report_invalid_issue(db, freezer, mocker, capsys): """Test case when issue is not available.""" From cc361020ff48022d090f46a152877ff4a812cb63 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 16 Oct 2017 15:19:04 +0200 Subject: [PATCH 252/980] Start only db with docker compose Server will be run locally. --- Makefile | 2 +- README.md | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 20c33b14a..9f3cb63d2 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ install-dev: ## Install development environment @pip install --upgrade -r dev_requirements.txt -e . start: ## Start the development server - @docker-compose start + @docker-compose start db @python manage.py runserver docs: diff --git a/README.md b/README.md index 197615967..5d7078929 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,7 @@ commands to complete the installation: ```bash $ echo "ENV=dev" >> .env # Django settings will be configured for development $ make install # Install Python requirements -$ docker-compose up -d # Start the containers -$ make setup-ldap # Configure UCS LDAP container -$ make create-ldap-user # Create a new standard user +$ docker-compose up -d db # Start the containers $ ./manage.py migrate # Run Django migrations $ ./manage.py createsuperuser # Create a new Django superuser ``` From 6ad8070cffeb6e0e31e83b0edb80675a3253308e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 16 Oct 2017 15:17:11 +0200 Subject: [PATCH 253/980] Remove obsolete sphinx docs README.md will be the source of documentation. --- Makefile | 3 - dev_requirements.txt | 2 - docs/Makefile | 20 ----- docs/source/_static/.gitkeep | 0 docs/source/conf.py | 163 ----------------------------------- docs/source/index.rst | 22 ----- docs/source/modules.rst | 8 -- docs/source/timed.rst | 18 ---- docs/source/timed_api.rst | 58 ------------- 9 files changed, 294 deletions(-) delete mode 100644 docs/Makefile delete mode 100644 docs/source/_static/.gitkeep delete mode 100644 docs/source/conf.py delete mode 100644 docs/source/index.rst delete mode 100644 docs/source/modules.rst delete mode 100644 docs/source/timed.rst delete mode 100644 docs/source/timed_api.rst diff --git a/Makefile b/Makefile index 20c33b14a..30badff7f 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,6 @@ start: ## Start the development server @docker-compose start @python manage.py runserver -docs: - @make -C docs/ html - test: ## Test the project ./manage.py migrate --noinput ./manage.py makemigrations --check --dry-run --noinput diff --git a/dev_requirements.txt b/dev_requirements.txt index 4d27c33d5..1f1905255 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -18,5 +18,3 @@ pytest-mock==1.6.2 # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af pytest-freezegun==0.1.0 -sphinx -sphinx_rtd_theme diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index a6b588835..000000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = Timed -SOURCEDIR = source -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/_static/.gitkeep b/docs/source/_static/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index a7a35f14f..000000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# Timed documentation build configuration file, created by -# sphinx-quickstart on Wed Jan 4 17:06:36 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. -# -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. - - -import os -import sys - -import django - -from timed import __version__ - -os.environ['DJANGO_SETTINGS_MODULE'] = 'timed.settings' - -sys.path.insert(0, os.path.abspath('../..')) -django.setup() - - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage'] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = 'Timed' -copyright = '2017, Adfinis SyGroup AG' -author = 'Adfinis SyGroup AG' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = __version__ -# The full version, including alpha/beta/rc tags. -release = version - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = [] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. -htmlhelp_basename = 'Timeddoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'Timed.tex', 'Timed Documentation', - 'Adfinis SyGroup AG', 'manual'), -] - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'timed', 'Timed Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'Timed', 'Timed Documentation', - author, 'Timed', 'One line description of project.', - 'Miscellaneous'), -] diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 913d9f6c8..000000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. Timed documentation master file, created by - sphinx-quickstart on Wed Jan 4 17:06:36 2017. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Timed -===== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - modules.rst - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index 2f197d37b..000000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,8 +0,0 @@ -Timed Backend -============= - -.. toctree:: - :maxdepth: 4 - - timed - timed_api diff --git a/docs/source/timed.rst b/docs/source/timed.rst deleted file mode 100644 index e49aa1431..000000000 --- a/docs/source/timed.rst +++ /dev/null @@ -1,18 +0,0 @@ -Timed -===== - -timed.jsonapi_test_case ------------------------ - -.. automodule:: timed.jsonapi_test_case - :members: - :undoc-members: - :show-inheritance: - -timed.middleware ----------------- - -.. automodule:: timed.middleware - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/timed_api.rst b/docs/source/timed_api.rst deleted file mode 100644 index 4beac2104..000000000 --- a/docs/source/timed_api.rst +++ /dev/null @@ -1,58 +0,0 @@ -Timed API -========= - -timed_api.admin ---------------- - -.. automodule:: timed_api.admin - :members: - :undoc-members: - :show-inheritance: - -timed_api.apps --------------- - -.. automodule:: timed_api.apps - :members: - :undoc-members: - :show-inheritance: - -timed_api.factories -------------------- - -.. automodule:: timed_api.factories - :members: - :undoc-members: - :show-inheritance: - -timed_api.filters ------------------ - -.. automodule:: timed_api.filters - :members: - :undoc-members: - :show-inheritance: - -timed_api.models ----------------- - -.. automodule:: timed_api.models - :members: - :undoc-members: - :show-inheritance: - -timed_api.serializers ---------------------- - -.. automodule:: timed_api.serializers - :members: - :undoc-members: - :show-inheritance: - -timed_api.views ---------------- - -.. automodule:: timed_api.views - :members: - :undoc-members: - :show-inheritance: From f14d3e34ce5111c327622624d8a5092ed9aa1fb0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 5 Oct 2017 09:58:51 +0200 Subject: [PATCH 254/980] Add by_year custom action to Report end point --- timed/serializers.py | 46 ++++++++++++++++++++++++++ timed/settings.py | 2 +- timed/tracking/serializers.py | 12 ++++++- timed/tracking/tests/test_report.py | 51 +++++++++++++++++++++++++++++ timed/tracking/views.py | 18 ++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 timed/serializers.py diff --git a/timed/serializers.py b/timed/serializers.py new file mode 100644 index 000000000..3f532db54 --- /dev/null +++ b/timed/serializers.py @@ -0,0 +1,46 @@ +from rest_framework_json_api.serializers import Serializer + + +class PkDict(dict): + """Dictionary with additional pk attribute.""" + + def __init__(self, pk, data): + super().__init__(**data) + self.pk = pk + + +class PkDictList(list): + """List of pk dicts with additional pk attribute.""" + + def __init__(self, pk_key, data): + super().__init__([PkDict(val[pk_key], val) for val in data]) + + +class PkDictSerializer(Serializer): + """ + Serializer wrapping dict adding pk attribute. + + Adds support to serialize plain dicts with json api renderer + as such expects a pk on each instance. + However using this serializer will also work with other renderers. + + Pk is determined by using `pk_key` configured on serializers + meta class. Additionally, `resource_name` needs to be assigned + to meta as well. + + Example: + >>> class MySerializer(PkDictSerializer): + ... # add your fields... + ... + ... class Meta: + ... pk_key = 'id' + ... resource_name = 'my-resource' + """ + + def __new__(cls, instance, **kwargs): + pk_key = cls.Meta.pk_key + if isinstance(instance, dict): + instance = PkDict(instance[pk_key], instance) + else: + instance = PkDictList(pk_key, instance) + return super().__new__(cls, instance, **kwargs) diff --git a/timed/settings.py b/timed/settings.py index fd589c78d..2cd9c167b 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -153,7 +153,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.DjangoFilterBackend', + 'django_filters.rest_framework.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', 'rest_framework.filters.OrderingFilter', ), diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 4ee45b212..b223774f6 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -7,13 +7,14 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, - DurationField, + DurationField, IntegerField, ModelSerializer, SerializerMethodField, ValidationError) from timed.employment.models import AbsenceType, Employment, PublicHoliday from timed.projects.models import Task +from timed.serializers import PkDictSerializer from timed.tracking import models @@ -115,6 +116,15 @@ class Meta: ] +class ReportByYearSerializer(PkDictSerializer): + duration = DurationField(read_only=True) + year = IntegerField(read_only=True) + + class Meta: + resource_name = 'report-year' + pk_key = 'year' + + class ReportSerializer(ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index af745d8e4..25bd10898 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -502,3 +502,54 @@ def test_report_list_no_result(admin_client): assert res.status_code == HTTP_200_OK json = res.json() assert json['meta']['total-time'] == '00:00:00' + + +def test_report_by_year(auth_client): + ReportFactory.create(duration=timedelta(hours=1), date=date(2017, 1, 1)) + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) + + url = reverse('report-by-year') + result = auth_client.get(url, data={'ordering': 'year'}) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'report-year', + 'id': '2015', + 'attributes': { + 'year': 2015, + 'duration': '02:00:00' + } + }, + { + 'type': 'report-year', + 'id': '2017', + 'attributes': { + 'year': 2017, + 'duration': '01:00:00' + } + } + ] + + assert json['data'] == expected_json + + +def test_report_by_month(auth_client): + ReportFactory.create(duration=timedelta(hours=1), date=date(2016, 1, 1)) + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 4)) + ReportFactory.create(duration=timedelta(hours=2), date=date(2015, 12, 31)) + + url = reverse('report-by-month') + result = auth_client.get(url, data={'ordering': 'year,month'}) + assert result.status_code == 200 + + json = result.json() + assert len(json['data']) == 2 + assert json['data'][0]['year'] == 2015 + assert json['data'][0]['month'] == 12 + assert json['data'][0]['duration'] == '03:00:00' + assert json['data'][1]['year'] == 2016 + assert json['data'][1]['month'] == 1 + assert json['data'][1]['duration'] == '01:00:00' diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 71275451a..56faadfab 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,7 +1,10 @@ """Viewsets for the tracking app.""" import django_excel +from django.db.models import Sum +from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponseBadRequest +from django.utils.duration import duration_string from rest_condition import C from rest_framework.decorators import list_route from rest_framework.response import Response @@ -152,6 +155,21 @@ def verify_list(self, request): return Response(data={}) + @list_route( + methods=['get'], + url_path='by-year', + serializer_class=serializers.ReportByYearSerializer, + ordering_fields=('year', 'duration') + ) + def by_year(self, request): + """Group report durations by year.""" + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.annotate(year=ExtractYear('date')).values('year') + queryset = queryset.annotate(duration=Sum('duration')) + + serializer = serializers.ReportByYearSerializer(queryset, many=True) + return Response(data=serializer.data) + def get_queryset(self): """Select related to reduce queries. From 434f835b5ccc48a726d4989a832b21234281c23f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 09:42:49 +0200 Subject: [PATCH 255/980] Add by_month custom action to Report end point --- timed/serializers.py | 21 ++++++++++++++++----- timed/tracking/serializers.py | 13 +++++++++++++ timed/tracking/tests/test_report.py | 29 ++++++++++++++++++++++------- timed/tracking/views.py | 18 +++++++++++++++++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/timed/serializers.py b/timed/serializers.py index 3f532db54..8d66fc0e4 100644 --- a/timed/serializers.py +++ b/timed/serializers.py @@ -12,8 +12,11 @@ def __init__(self, pk, data): class PkDictList(list): """List of pk dicts with additional pk attribute.""" - def __init__(self, pk_key, data): - super().__init__([PkDict(val[pk_key], val) for val in data]) + def __init__(self, serializer, data): + super().__init__([ + PkDict(serializer.get_pk(val), val) + for val in data + ]) class PkDictSerializer(Serializer): @@ -38,9 +41,17 @@ class PkDictSerializer(Serializer): """ def __new__(cls, instance, **kwargs): - pk_key = cls.Meta.pk_key if isinstance(instance, dict): - instance = PkDict(instance[pk_key], instance) + instance = PkDict(cls.get_pk(instance), instance) else: - instance = PkDictList(pk_key, instance) + instance = PkDictList(cls, instance) return super().__new__(cls, instance, **kwargs) + + @classmethod + def get_pk(cls, item): + """ + Get primary key of given item. + + Takes dict value of configured pk_key. + """ + return item[cls.Meta.pk_key] diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index b223774f6..572f58135 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -125,6 +125,19 @@ class Meta: pk_key = 'year' +class ReportByMonthSerializer(PkDictSerializer): + duration = DurationField(read_only=True) + year = IntegerField(read_only=True) + month = IntegerField(read_only=True) + + @classmethod + def get_pk(cls, item): + return '{0}-{1}'.format(item['year'], item['month']) + + class Meta: + resource_name = 'report-month' + + class ReportSerializer(ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 25bd10898..7c9132524 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -546,10 +546,25 @@ def test_report_by_month(auth_client): assert result.status_code == 200 json = result.json() - assert len(json['data']) == 2 - assert json['data'][0]['year'] == 2015 - assert json['data'][0]['month'] == 12 - assert json['data'][0]['duration'] == '03:00:00' - assert json['data'][1]['year'] == 2016 - assert json['data'][1]['month'] == 1 - assert json['data'][1]['duration'] == '01:00:00' + expected_json = [ + { + 'type': 'report-month', + 'id': '2015-12', + 'attributes': { + 'year': 2015, + 'month': 12, + 'duration': '03:00:00' + } + }, + { + 'type': 'report-month', + 'id': '2016-1', + 'attributes': { + 'year': 2016, + 'month': 1, + 'duration': '01:00:00' + } + } + ] + + assert json['data'] == expected_json diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 56faadfab..98bcfafb1 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -4,7 +4,6 @@ from django.db.models import Sum from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponseBadRequest -from django.utils.duration import duration_string from rest_condition import C from rest_framework.decorators import list_route from rest_framework.response import Response @@ -170,6 +169,23 @@ def by_year(self, request): serializer = serializers.ReportByYearSerializer(queryset, many=True) return Response(data=serializer.data) + @list_route( + methods=['get'], + url_path='by-month', + serializer_class=serializers.ReportByMonthSerializer, + ordering_fields=('year', 'month', 'duration') + ) + def by_month(self, request): + """Group report durations by month.""" + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.annotate( + year=ExtractYear('date'), month=ExtractMonth('date') + ) + queryset = queryset.values('year', 'month') + queryset = queryset.annotate(duration=Sum('duration')) + serializer = serializers.ReportByMonthSerializer(queryset, many=True) + return Response(data=serializer.data) + def get_queryset(self): """Select related to reduce queries. From a932fe7da62c429aa5d64628a871418a0d6a7a7a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 09:43:03 +0200 Subject: [PATCH 256/980] Move timed module tests into its own module --- timed/tests/__init__.py | 0 timed/{ => tests}/test_settings.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 timed/tests/__init__.py rename timed/{ => tests}/test_settings.py (100%) diff --git a/timed/tests/__init__.py b/timed/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/test_settings.py b/timed/tests/test_settings.py similarity index 100% rename from timed/test_settings.py rename to timed/tests/test_settings.py From 5adaa6ec7b1c379d0fcbf7b488cc48e183739f0e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 10:00:52 +0200 Subject: [PATCH 257/980] Add unit test for pk dict serializer --- timed/tests/test_serializers.py | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 timed/tests/test_serializers.py diff --git a/timed/tests/test_serializers.py b/timed/tests/test_serializers.py new file mode 100644 index 000000000..68e7b2041 --- /dev/null +++ b/timed/tests/test_serializers.py @@ -0,0 +1,56 @@ +from datetime import timedelta + +import pytest +from rest_framework_json_api.serializers import DurationField, IntegerField + +from timed.serializers import PkDictSerializer + + +class MyPkDictSerializer(PkDictSerializer): + test_duration = DurationField() + test_nr = IntegerField() + + class Meta: + pk_key = 'test_nr' + resource_name = 'my-resource' + + +@pytest.fixture +def data(): + return { + 'test_nr': 123, + 'test_duration': timedelta(hours=1), + 'invalid_field': '1234' + } + + +def test_pk_dict_serializer_single(data): + serializer = MyPkDictSerializer(data) + + expected_data = { + 'test_duration': '01:00:00', + 'test_nr': 123, + } + + assert expected_data == serializer.data + + +def test_pk_dict_serializer_many(data): + list_data = [ + data, + data + ] + serializer = MyPkDictSerializer(list_data, many=True) + + expected_data = [ + { + 'test_duration': '01:00:00', + 'test_nr': 123, + }, + { + 'test_duration': '01:00:00', + 'test_nr': 123, + }, + ] + + assert expected_data == serializer.data From 2fb5dcd98a1c702c6382fa163498aad40d9373c7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 14:21:34 +0200 Subject: [PATCH 258/980] Convert PkDictSerializer to DictObjectSerializer User of DictObjectSerializer need to create pk in dict however this serializer also means it works with related fields. --- timed/serializers.py | 51 +++++++++++---------------------- timed/tests/test_serializers.py | 4 +-- timed/tracking/serializers.py | 11 ++----- timed/tracking/views.py | 6 ++-- 4 files changed, 25 insertions(+), 47 deletions(-) diff --git a/timed/serializers.py b/timed/serializers.py index 8d66fc0e4..08a50728b 100644 --- a/timed/serializers.py +++ b/timed/serializers.py @@ -1,57 +1,38 @@ from rest_framework_json_api.serializers import Serializer -class PkDict(dict): - """Dictionary with additional pk attribute.""" - - def __init__(self, pk, data): - super().__init__(**data) - self.pk = pk - +class DictObject(dict): + """ + Wrap dict into an object. -class PkDictList(list): - """List of pk dicts with additional pk attribute.""" + All values will be accesible through attributes. Note that + keys must be valid python names for this to work. + """ - def __init__(self, serializer, data): - super().__init__([ - PkDict(serializer.get_pk(val), val) - for val in data - ]) + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + super().__init__(**kwargs) -class PkDictSerializer(Serializer): +class DictObjectSerializer(Serializer): """ - Serializer wrapping dict adding pk attribute. + Serializer wrapping object into a `DictObject`. Adds support to serialize plain dicts with json api renderer - as such expects a pk on each instance. - However using this serializer will also work with other renderers. - - Pk is determined by using `pk_key` configured on serializers - meta class. Additionally, `resource_name` needs to be assigned - to meta as well. + as such expects a values to be attributes. + Note that dict needs to have a pk key to work as json api resource. Example: - >>> class MySerializer(PkDictSerializer): + >>> class MySerializer(DictObjectSerializer): ... # add your fields... ... ... class Meta: - ... pk_key = 'id' ... resource_name = 'my-resource' """ def __new__(cls, instance, **kwargs): if isinstance(instance, dict): - instance = PkDict(cls.get_pk(instance), instance) + instance = DictObject(**instance) else: - instance = PkDictList(cls, instance) + instance = [DictObject(**entry) for entry in instance] return super().__new__(cls, instance, **kwargs) - - @classmethod - def get_pk(cls, item): - """ - Get primary key of given item. - - Takes dict value of configured pk_key. - """ - return item[cls.Meta.pk_key] diff --git a/timed/tests/test_serializers.py b/timed/tests/test_serializers.py index 68e7b2041..3ae9358c7 100644 --- a/timed/tests/test_serializers.py +++ b/timed/tests/test_serializers.py @@ -3,10 +3,10 @@ import pytest from rest_framework_json_api.serializers import DurationField, IntegerField -from timed.serializers import PkDictSerializer +from timed.serializers import DictObjectSerializer -class MyPkDictSerializer(PkDictSerializer): +class MyPkDictSerializer(DictObjectSerializer): test_duration = DurationField() test_nr = IntegerField() diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 572f58135..a674a9ce3 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -14,7 +14,7 @@ from timed.employment.models import AbsenceType, Employment, PublicHoliday from timed.projects.models import Task -from timed.serializers import PkDictSerializer +from timed.serializers import DictObjectSerializer from timed.tracking import models @@ -116,24 +116,19 @@ class Meta: ] -class ReportByYearSerializer(PkDictSerializer): +class ReportByYearSerializer(DictObjectSerializer): duration = DurationField(read_only=True) year = IntegerField(read_only=True) class Meta: resource_name = 'report-year' - pk_key = 'year' -class ReportByMonthSerializer(PkDictSerializer): +class ReportByMonthSerializer(DictObjectSerializer): duration = DurationField(read_only=True) year = IntegerField(read_only=True) month = IntegerField(read_only=True) - @classmethod - def get_pk(cls, item): - return '{0}-{1}'.format(item['year'], item['month']) - class Meta: resource_name = 'report-month' diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 98bcfafb1..68ef096d3 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,8 +1,8 @@ """Viewsets for the tracking app.""" import django_excel -from django.db.models import Sum -from django.db.models.functions import ExtractMonth, ExtractYear +from django.db.models import F, Sum, Value +from django.db.models.functions import Concat, ExtractMonth, ExtractYear from django.http import HttpResponseBadRequest from rest_condition import C from rest_framework.decorators import list_route @@ -165,6 +165,7 @@ def by_year(self, request): queryset = self.filter_queryset(self.get_queryset()) queryset = queryset.annotate(year=ExtractYear('date')).values('year') queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('year')) serializer = serializers.ReportByYearSerializer(queryset, many=True) return Response(data=serializer.data) @@ -183,6 +184,7 @@ def by_month(self, request): ) queryset = queryset.values('year', 'month') queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=Concat('year', Value('-'), 'month')) serializer = serializers.ReportByMonthSerializer(queryset, many=True) return Response(data=serializer.data) From 3d638df2340246a3aba7ff96a83b40a067d5aa4e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 14:34:47 +0200 Subject: [PATCH 259/980] Add group reports by_user custom action --- timed/relations.py | 21 +++++++++++++ timed/tracking/serializers.py | 9 ++++++ timed/tracking/tests/test_report.py | 47 +++++++++++++++++++++++++++++ timed/tracking/views.py | 15 +++++++++ 4 files changed, 92 insertions(+) create mode 100644 timed/relations.py diff --git a/timed/relations.py b/timed/relations.py new file mode 100644 index 000000000..ce4049924 --- /dev/null +++ b/timed/relations.py @@ -0,0 +1,21 @@ +from collections import OrderedDict + +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.utils import get_resource_type_from_model + + +class IdResourceRelatedField(ResourceRelatedField): + """ + Resource related field whereas resource is represented by id only. + + As value doesn't represent what model it is from `model` needs to + be defined as kwarg. + """ + + def to_representation(self, value): + # TODO wrap value into a object with value as pk + # and add model class to meta on the fly + # This way it would be possible to call super to_representation + # with all its functionality. + resource_type = get_resource_type_from_model(self.model) + return OrderedDict([('type', resource_type), ('id', str(value))]) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index a674a9ce3..d51154a0b 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -14,6 +14,7 @@ from timed.employment.models import AbsenceType, Employment, PublicHoliday from timed.projects.models import Task +from timed.relations import IdResourceRelatedField from timed.serializers import DictObjectSerializer from timed.tracking import models @@ -133,6 +134,14 @@ class Meta: resource_name = 'report-month' +class ReportByUserSerializer(DictObjectSerializer): + duration = DurationField(read_only=True) + user = IdResourceRelatedField(model=get_user_model(), read_only=True) + + class Meta: + resource_name = 'report-user' + + class ReportSerializer(ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 7c9132524..523bb04da 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -568,3 +568,50 @@ def test_report_by_month(auth_client): ] assert json['data'] == expected_json + + +def test_report_by_user(auth_client): + user = auth_client.user + ReportFactory.create(duration=timedelta(hours=1), user=user) + ReportFactory.create(duration=timedelta(hours=2), user=user) + report = ReportFactory.create(duration=timedelta(hours=2)) + + url = reverse('report-by-user') + result = auth_client.get(url, data={'ordering': 'duration'}) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'report-user', + 'id': str(report.user.id), + 'attributes': { + 'duration': '02:00:00' + }, + 'relationships': { + 'user': { + 'data': { + 'id': str(report.user.id), + 'type': 'users' + } + } + } + }, + { + 'type': 'report-user', + 'id': str(user.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'user': { + 'data': { + 'id': str(user.id), + 'type': 'users' + } + } + } + } + ] + + assert json['data'] == expected_json diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 68ef096d3..f23c9db3c 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -188,6 +188,21 @@ def by_month(self, request): serializer = serializers.ReportByMonthSerializer(queryset, many=True) return Response(data=serializer.data) + @list_route( + methods=['get'], + url_path='by-user', + serializer_class=serializers.ReportByUserSerializer, + ordering_fields=('user__username', 'duration') + ) + def by_user(self, request): + """Group report durations by user.""" + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.values('user') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('user')) + serializer = serializers.ReportByUserSerializer(queryset, many=True) + return Response(data=serializer.data) + def get_queryset(self): """Select related to reduce queries. From 0fc8322bf1592d948b1d241a050657e857a269a7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 14:46:30 +0200 Subject: [PATCH 260/980] Add group reports by_task custom action --- timed/tracking/serializers.py | 9 ++++++ timed/tracking/tests/test_report.py | 48 +++++++++++++++++++++++++++++ timed/tracking/views.py | 15 +++++++++ 3 files changed, 72 insertions(+) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index d51154a0b..f097eed2f 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -118,6 +118,7 @@ class Meta: class ReportByYearSerializer(DictObjectSerializer): + # TODO add total duration = DurationField(read_only=True) year = IntegerField(read_only=True) @@ -142,6 +143,14 @@ class Meta: resource_name = 'report-user' +class ReportByTaskSerializer(DictObjectSerializer): + duration = DurationField(read_only=True) + task = IdResourceRelatedField(model=Task, read_only=True) + + class Meta: + resource_name = 'report-task' + + class ReportSerializer(ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 523bb04da..3e7452694 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -615,3 +615,51 @@ def test_report_by_user(auth_client): ] assert json['data'] == expected_json + + +def test_report_by_task(auth_client): + task_z = TaskFactory.create(name='Z') + task_test = TaskFactory.create(name='Test') + ReportFactory.create(duration=timedelta(hours=1), task=task_test) + ReportFactory.create(duration=timedelta(hours=2), task=task_test) + ReportFactory.create(duration=timedelta(hours=2), task=task_z) + + url = reverse('report-by-task') + result = auth_client.get(url, data={'ordering': 'task__name'}) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'report-task', + 'id': str(task_test.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'task': { + 'data': { + 'id': str(task_test.id), + 'type': 'tasks' + } + } + } + }, + { + 'type': 'report-task', + 'id': str(task_z.id), + 'attributes': { + 'duration': '02:00:00' + }, + 'relationships': { + 'task': { + 'data': { + 'id': str(task_z.id), + 'type': 'tasks' + } + } + } + } + ] + + assert json['data'] == expected_json diff --git a/timed/tracking/views.py b/timed/tracking/views.py index f23c9db3c..55758bd96 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -203,6 +203,21 @@ def by_user(self, request): serializer = serializers.ReportByUserSerializer(queryset, many=True) return Response(data=serializer.data) + @list_route( + methods=['get'], + url_path='by-task', + serializer_class=serializers.ReportByTaskSerializer, + ordering_fields=('task__name', 'duration') + ) + def by_task(self, request): + """Group report durations by task.""" + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.values('task') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('task')) + serializer = serializers.ReportByTaskSerializer(queryset, many=True) + return Response(data=serializer.data) + def get_queryset(self): """Select related to reduce queries. From 7da44d449bd085dcd269094eda1591f53dcd29f6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 14:56:17 +0200 Subject: [PATCH 261/980] Add report group by project custom end point --- timed/tracking/serializers.py | 11 ++++++- timed/tracking/tests/test_report.py | 46 +++++++++++++++++++++++++++++ timed/tracking/views.py | 15 ++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index f097eed2f..4bf343028 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -13,7 +13,7 @@ ValidationError) from timed.employment.models import AbsenceType, Employment, PublicHoliday -from timed.projects.models import Task +from timed.projects.models import Project, Task from timed.relations import IdResourceRelatedField from timed.serializers import DictObjectSerializer from timed.tracking import models @@ -151,6 +151,15 @@ class Meta: resource_name = 'report-task' +class ReportByProjectSerializer(DictObjectSerializer): + duration = DurationField(read_only=True) + project = IdResourceRelatedField(source='task__project', model=Project, + read_only=True) + + class Meta: + resource_name = 'report-project' + + class ReportSerializer(ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 3e7452694..1da847281 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -663,3 +663,49 @@ def test_report_by_task(auth_client): ] assert json['data'] == expected_json + + +def test_report_by_project(auth_client): + report = ReportFactory.create(duration=timedelta(hours=1)) + ReportFactory.create(duration=timedelta(hours=2), task=report.task) + report2 = ReportFactory.create(duration=timedelta(hours=4)) + + url = reverse('report-by-project') + result = auth_client.get(url, data={'ordering': 'duration'}) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'report-project', + 'id': str(report.task.project.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'project': { + 'data': { + 'id': str(report.task.project.id), + 'type': 'projects' + } + } + } + }, + { + 'type': 'report-project', + 'id': str(report2.task.project.id), + 'attributes': { + 'duration': '04:00:00' + }, + 'relationships': { + 'project': { + 'data': { + 'id': str(report2.task.project.id), + 'type': 'projects' + } + } + } + } + ] + + assert json['data'] == expected_json diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 55758bd96..9b34c50b9 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -218,6 +218,21 @@ def by_task(self, request): serializer = serializers.ReportByTaskSerializer(queryset, many=True) return Response(data=serializer.data) + @list_route( + methods=['get'], + url_path='by-project', + serializer_class=serializers.ReportByProjectSerializer, + ordering_fields=('task__project__name', 'duration') + ) + def by_project(self, request): + """Group report durations by project.""" + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.values('task__project') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('task__project')) + serializer = serializers.ReportByProjectSerializer(queryset, many=True) + return Response(data=serializer.data) + def get_queryset(self): """Select related to reduce queries. From da977a2ebeba8a2cf1fcc45c3fcd5c69c23c25e5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 15:01:58 +0200 Subject: [PATCH 262/980] Add group by reports by customer route --- timed/tracking/serializers.py | 11 ++++++- timed/tracking/tests/test_report.py | 46 +++++++++++++++++++++++++++++ timed/tracking/views.py | 17 +++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 4bf343028..ef7714f62 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -13,7 +13,7 @@ ValidationError) from timed.employment.models import AbsenceType, Employment, PublicHoliday -from timed.projects.models import Project, Task +from timed.projects.models import Customer, Project, Task from timed.relations import IdResourceRelatedField from timed.serializers import DictObjectSerializer from timed.tracking import models @@ -160,6 +160,15 @@ class Meta: resource_name = 'report-project' +class ReportByCustomerSerializer(DictObjectSerializer): + duration = DurationField(read_only=True) + customer = IdResourceRelatedField(source='task__project__customer', + model=Customer, read_only=True) + + class Meta: + resource_name = 'report-customer' + + class ReportSerializer(ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 1da847281..e9038e4db 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -709,3 +709,49 @@ def test_report_by_project(auth_client): ] assert json['data'] == expected_json + + +def test_report_by_customer(auth_client): + report = ReportFactory.create(duration=timedelta(hours=1)) + ReportFactory.create(duration=timedelta(hours=2), task=report.task) + report2 = ReportFactory.create(duration=timedelta(hours=4)) + + url = reverse('report-by-customer') + result = auth_client.get(url, data={'ordering': 'duration'}) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'report-customer', + 'id': str(report.task.project.customer.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'customer': { + 'data': { + 'id': str(report.task.project.customer.id), + 'type': 'customers' + } + } + } + }, + { + 'type': 'report-customer', + 'id': str(report2.task.project.customer.id), + 'attributes': { + 'duration': '04:00:00' + }, + 'relationships': { + 'customer': { + 'data': { + 'id': str(report2.task.project.customer.id), + 'type': 'customers' + } + } + } + } + ] + + assert json['data'] == expected_json diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 9b34c50b9..8aca06311 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -233,6 +233,23 @@ def by_project(self, request): serializer = serializers.ReportByProjectSerializer(queryset, many=True) return Response(data=serializer.data) + @list_route( + methods=['get'], + url_path='by-customer', + serializer_class=serializers.ReportByCustomerSerializer, + ordering_fields=('task__project__customer__name', 'duration') + ) + def by_customer(self, request): + """Group report durations by customer.""" + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.values('task__project__customer') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('task__project__customer')) + serializer = serializers.ReportByCustomerSerializer( + queryset, many=True + ) + return Response(data=serializer.data) + def get_queryset(self): """Select related to reduce queries. From dd4eab48275371b09183b407382ada2ecfe1cb43 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 15:16:38 +0200 Subject: [PATCH 263/980] Add total time to grouped report serializer --- timed/tracking/serializers.py | 41 +++++++++++++++-------------- timed/tracking/tests/test_report.py | 10 +++++-- timed/tracking/views.py | 22 +++++++++++----- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index ef7714f62..ab048714b 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -117,8 +117,21 @@ class Meta: ] -class ReportByYearSerializer(DictObjectSerializer): - # TODO add total +class TotalTimeRootMetaMixin(object): + def get_root_meta(self, resource, many): + """Add total hours over whole result (not just page) to meta.""" + if many: + view = self.context['view'] + queryset = view.filter_queryset(view.get_queryset()) + data = queryset.aggregate(total_time=Sum('duration')) + data['total_time'] = duration_string( + data['total_time'] or timedelta(0) + ) + return data + return {} + + +class ReportByYearSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) year = IntegerField(read_only=True) @@ -126,7 +139,7 @@ class Meta: resource_name = 'report-year' -class ReportByMonthSerializer(DictObjectSerializer): +class ReportByMonthSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) year = IntegerField(read_only=True) month = IntegerField(read_only=True) @@ -135,7 +148,7 @@ class Meta: resource_name = 'report-month' -class ReportByUserSerializer(DictObjectSerializer): +class ReportByUserSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) user = IdResourceRelatedField(model=get_user_model(), read_only=True) @@ -143,7 +156,7 @@ class Meta: resource_name = 'report-user' -class ReportByTaskSerializer(DictObjectSerializer): +class ReportByTaskSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) task = IdResourceRelatedField(model=Task, read_only=True) @@ -151,7 +164,7 @@ class Meta: resource_name = 'report-task' -class ReportByProjectSerializer(DictObjectSerializer): +class ReportByProjectSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) project = IdResourceRelatedField(source='task__project', model=Project, read_only=True) @@ -160,7 +173,7 @@ class Meta: resource_name = 'report-project' -class ReportByCustomerSerializer(DictObjectSerializer): +class ReportByCustomerSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) customer = IdResourceRelatedField(source='task__project__customer', model=Customer, read_only=True) @@ -169,7 +182,7 @@ class Meta: resource_name = 'report-customer' -class ReportSerializer(ModelSerializer): +class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): """Report serializer.""" task = ResourceRelatedField(queryset=Task.objects.all()) @@ -225,18 +238,6 @@ def validate_duration(self, value): return value - def get_root_meta(self, resource, many): - """Add total hours over whole result (not just page) to meta.""" - if many: - view = self.context['view'] - queryset = view.filter_queryset(view.get_queryset()) - data = queryset.aggregate(total_time=Sum('duration')) - data['total_time'] = duration_string( - data['total_time'] or timedelta(0) - ) - return data - return {} - class Meta: """Meta information for the report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index e9038e4db..d9a76c6fa 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -534,6 +534,7 @@ def test_report_by_year(auth_client): ] assert json['data'] == expected_json + assert json['meta']['total-time'] == '03:00:00' def test_report_by_month(auth_client): @@ -568,6 +569,7 @@ def test_report_by_month(auth_client): ] assert json['data'] == expected_json + assert json['meta']['total-time'] == '04:00:00' def test_report_by_user(auth_client): @@ -615,6 +617,7 @@ def test_report_by_user(auth_client): ] assert json['data'] == expected_json + assert json['meta']['total-time'] == '05:00:00' def test_report_by_task(auth_client): @@ -663,6 +666,7 @@ def test_report_by_task(auth_client): ] assert json['data'] == expected_json + assert json['meta']['total-time'] == '05:00:00' def test_report_by_project(auth_client): @@ -709,6 +713,7 @@ def test_report_by_project(auth_client): ] assert json['data'] == expected_json + assert json['meta']['total-time'] == '07:00:00' def test_report_by_customer(auth_client): @@ -721,7 +726,7 @@ def test_report_by_customer(auth_client): assert result.status_code == 200 json = result.json() - expected_json = [ + expected_data = [ { 'type': 'report-customer', 'id': str(report.task.project.customer.id), @@ -754,4 +759,5 @@ def test_report_by_customer(auth_client): } ] - assert json['data'] == expected_json + assert json['data'] == expected_data + assert json['meta']['total-time'] == '07:00:00' diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 8aca06311..76e170833 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -167,7 +167,9 @@ def by_year(self, request): queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('year')) - serializer = serializers.ReportByYearSerializer(queryset, many=True) + serializer = serializers.ReportByYearSerializer( + queryset, many=True, context={'view': self} + ) return Response(data=serializer.data) @list_route( @@ -185,7 +187,9 @@ def by_month(self, request): queryset = queryset.values('year', 'month') queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=Concat('year', Value('-'), 'month')) - serializer = serializers.ReportByMonthSerializer(queryset, many=True) + serializer = serializers.ReportByMonthSerializer( + queryset, many=True, context={'view': self} + ) return Response(data=serializer.data) @list_route( @@ -200,7 +204,9 @@ def by_user(self, request): queryset = queryset.values('user') queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('user')) - serializer = serializers.ReportByUserSerializer(queryset, many=True) + serializer = serializers.ReportByUserSerializer( + queryset, many=True, context={'view': self} + ) return Response(data=serializer.data) @list_route( @@ -215,7 +221,9 @@ def by_task(self, request): queryset = queryset.values('task') queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('task')) - serializer = serializers.ReportByTaskSerializer(queryset, many=True) + serializer = serializers.ReportByTaskSerializer( + queryset, many=True, context={'view': self} + ) return Response(data=serializer.data) @list_route( @@ -230,7 +238,9 @@ def by_project(self, request): queryset = queryset.values('task__project') queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('task__project')) - serializer = serializers.ReportByProjectSerializer(queryset, many=True) + serializer = serializers.ReportByProjectSerializer( + queryset, many=True, context={'view': self} + ) return Response(data=serializer.data) @list_route( @@ -246,7 +256,7 @@ def by_customer(self, request): queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('task__project__customer')) serializer = serializers.ReportByCustomerSerializer( - queryset, many=True + queryset, many=True, context={'view': self} ) return Response(data=serializer.data) From 17193f9898af6fd16ff84e7107c5f0b29a3117ed Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Oct 2017 15:25:03 +0200 Subject: [PATCH 264/980] Clarify DictObjectSerializer docu --- timed/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/serializers.py b/timed/serializers.py index 08a50728b..b30b83374 100644 --- a/timed/serializers.py +++ b/timed/serializers.py @@ -19,7 +19,7 @@ class DictObjectSerializer(Serializer): Serializer wrapping object into a `DictObject`. Adds support to serialize plain dicts with json api renderer - as such expects a values to be attributes. + as such expects values to be attributes. Note that dict needs to have a pk key to work as json api resource. Example: From 456a82cb90a22abf04365256c95b4974a5db6536 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Oct 2017 13:36:39 +0200 Subject: [PATCH 265/980] Set correct default ordering --- timed/tracking/views.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 76e170833..280efad61 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -158,7 +158,8 @@ def verify_list(self, request): methods=['get'], url_path='by-year', serializer_class=serializers.ReportByYearSerializer, - ordering_fields=('year', 'duration') + ordering_fields=('year', 'duration'), + ordering=('year', ) ) def by_year(self, request): """Group report durations by year.""" @@ -176,7 +177,8 @@ def by_year(self, request): methods=['get'], url_path='by-month', serializer_class=serializers.ReportByMonthSerializer, - ordering_fields=('year', 'month', 'duration') + ordering_fields=('year', 'month', 'duration'), + ordering=('year', 'month') ) def by_month(self, request): """Group report durations by month.""" @@ -196,7 +198,8 @@ def by_month(self, request): methods=['get'], url_path='by-user', serializer_class=serializers.ReportByUserSerializer, - ordering_fields=('user__username', 'duration') + ordering_fields=('user__username', 'duration'), + ordering=('user__username', ) ) def by_user(self, request): """Group report durations by user.""" @@ -213,7 +216,8 @@ def by_user(self, request): methods=['get'], url_path='by-task', serializer_class=serializers.ReportByTaskSerializer, - ordering_fields=('task__name', 'duration') + ordering_fields=('task__name', 'duration'), + ordering=('task__name', ) ) def by_task(self, request): """Group report durations by task.""" @@ -230,7 +234,8 @@ def by_task(self, request): methods=['get'], url_path='by-project', serializer_class=serializers.ReportByProjectSerializer, - ordering_fields=('task__project__name', 'duration') + ordering_fields=('task__project__name', 'duration'), + ordering=('task__project__name', ) ) def by_project(self, request): """Group report durations by project.""" @@ -247,7 +252,8 @@ def by_project(self, request): methods=['get'], url_path='by-customer', serializer_class=serializers.ReportByCustomerSerializer, - ordering_fields=('task__project__customer__name', 'duration') + ordering_fields=('task__project__customer__name', 'duration'), + ordering=('task__project__customer__name', ) ) def by_customer(self, request): """Group report durations by customer.""" From 2564397146969e5b539417b1eee688ef55e5b030 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 11 Oct 2017 09:08:41 +0200 Subject: [PATCH 266/980] Resource name need to be plural --- timed/tracking/serializers.py | 12 ++++++------ timed/tracking/tests/test_report.py | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index ab048714b..10aa82595 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -136,7 +136,7 @@ class ReportByYearSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): year = IntegerField(read_only=True) class Meta: - resource_name = 'report-year' + resource_name = 'report-years' class ReportByMonthSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): @@ -145,7 +145,7 @@ class ReportByMonthSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): month = IntegerField(read_only=True) class Meta: - resource_name = 'report-month' + resource_name = 'report-months' class ReportByUserSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): @@ -153,7 +153,7 @@ class ReportByUserSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): user = IdResourceRelatedField(model=get_user_model(), read_only=True) class Meta: - resource_name = 'report-user' + resource_name = 'report-users' class ReportByTaskSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): @@ -161,7 +161,7 @@ class ReportByTaskSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): task = IdResourceRelatedField(model=Task, read_only=True) class Meta: - resource_name = 'report-task' + resource_name = 'report-tasks' class ReportByProjectSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): @@ -170,7 +170,7 @@ class ReportByProjectSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): read_only=True) class Meta: - resource_name = 'report-project' + resource_name = 'report-projects' class ReportByCustomerSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): @@ -179,7 +179,7 @@ class ReportByCustomerSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): model=Customer, read_only=True) class Meta: - resource_name = 'report-customer' + resource_name = 'report-customers' class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index d9a76c6fa..fad9fe649 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -516,7 +516,7 @@ def test_report_by_year(auth_client): json = result.json() expected_json = [ { - 'type': 'report-year', + 'type': 'report-years', 'id': '2015', 'attributes': { 'year': 2015, @@ -524,7 +524,7 @@ def test_report_by_year(auth_client): } }, { - 'type': 'report-year', + 'type': 'report-years', 'id': '2017', 'attributes': { 'year': 2017, @@ -549,7 +549,7 @@ def test_report_by_month(auth_client): json = result.json() expected_json = [ { - 'type': 'report-month', + 'type': 'report-months', 'id': '2015-12', 'attributes': { 'year': 2015, @@ -558,7 +558,7 @@ def test_report_by_month(auth_client): } }, { - 'type': 'report-month', + 'type': 'report-months', 'id': '2016-1', 'attributes': { 'year': 2016, @@ -585,7 +585,7 @@ def test_report_by_user(auth_client): json = result.json() expected_json = [ { - 'type': 'report-user', + 'type': 'report-users', 'id': str(report.user.id), 'attributes': { 'duration': '02:00:00' @@ -600,7 +600,7 @@ def test_report_by_user(auth_client): } }, { - 'type': 'report-user', + 'type': 'report-users', 'id': str(user.id), 'attributes': { 'duration': '03:00:00' @@ -634,7 +634,7 @@ def test_report_by_task(auth_client): json = result.json() expected_json = [ { - 'type': 'report-task', + 'type': 'report-tasks', 'id': str(task_test.id), 'attributes': { 'duration': '03:00:00' @@ -649,7 +649,7 @@ def test_report_by_task(auth_client): } }, { - 'type': 'report-task', + 'type': 'report-tasks', 'id': str(task_z.id), 'attributes': { 'duration': '02:00:00' @@ -681,7 +681,7 @@ def test_report_by_project(auth_client): json = result.json() expected_json = [ { - 'type': 'report-project', + 'type': 'report-projects', 'id': str(report.task.project.id), 'attributes': { 'duration': '03:00:00' @@ -696,7 +696,7 @@ def test_report_by_project(auth_client): } }, { - 'type': 'report-project', + 'type': 'report-projects', 'id': str(report2.task.project.id), 'attributes': { 'duration': '04:00:00' @@ -728,7 +728,7 @@ def test_report_by_customer(auth_client): json = result.json() expected_data = [ { - 'type': 'report-customer', + 'type': 'report-customers', 'id': str(report.task.project.customer.id), 'attributes': { 'duration': '03:00:00' @@ -743,7 +743,7 @@ def test_report_by_customer(auth_client): } }, { - 'type': 'report-customer', + 'type': 'report-customers', 'id': str(report2.task.project.customer.id), 'attributes': { 'duration': '04:00:00' From 58f5c565aa9d469d29abfd5305811aac3ec6ffeb Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 11 Oct 2017 16:48:42 +0200 Subject: [PATCH 267/980] Add support to grouping reports for included serializers --- timed/employment/serializers.py | 8 ++--- timed/relations.py | 21 ------------- timed/tracking/serializers.py | 49 ++++++++++++++++++++++++----- timed/tracking/tests/test_report.py | 24 +++++++++++--- timed/tracking/views.py | 16 +++++----- 5 files changed, 73 insertions(+), 45 deletions(-) delete mode 100644 timed/relations.py diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 2e3224cc7..1a058a69f 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -32,12 +32,10 @@ def get_user_absence_types(self, instance): :returns: All absence types for this user """ request = self.context.get('request') + until = request and request.query_params.get('until') end = datetime.strptime( - request.query_params.get( - 'until', - date.today().strftime('%Y-%m-%d') - ), + until or date.today().strftime('%Y-%m-%d'), '%Y-%m-%d' ).date() start = date(end.year, 1, 1) @@ -60,7 +58,7 @@ def get_worktime_balance(self, instance): :rtype: str """ request = self.context.get('request') - until = request.query_params.get('until') + until = request and request.query_params.get('until') end_date = until and datetime.strptime(until, '%Y-%m-%d').date() _, _, balance = self.get_worktime(instance, None, end_date) diff --git a/timed/relations.py b/timed/relations.py deleted file mode 100644 index ce4049924..000000000 --- a/timed/relations.py +++ /dev/null @@ -1,21 +0,0 @@ -from collections import OrderedDict - -from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.utils import get_resource_type_from_model - - -class IdResourceRelatedField(ResourceRelatedField): - """ - Resource related field whereas resource is represented by id only. - - As value doesn't represent what model it is from `model` needs to - be defined as kwarg. - """ - - def to_representation(self, value): - # TODO wrap value into a object with value as pk - # and add model class to meta on the fly - # This way it would be possible to call super to_representation - # with all its functionality. - resource_type = get_resource_type_from_model(self.model) - return OrderedDict([('type', resource_type), ('id', str(value))]) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 10aa82595..b1c66f436 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -5,6 +5,7 @@ from django.db.models import Sum from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ +from rest_framework_json_api import relations from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, DurationField, IntegerField, @@ -14,7 +15,6 @@ from timed.employment.models import AbsenceType, Employment, PublicHoliday from timed.projects.models import Customer, Project, Task -from timed.relations import IdResourceRelatedField from timed.serializers import DictObjectSerializer from timed.tracking import models @@ -150,7 +150,17 @@ class Meta: class ReportByUserSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - user = IdResourceRelatedField(model=get_user_model(), read_only=True) + user = relations.SerializerMethodResourceRelatedField( + source='get_user', model=get_user_model(), read_only=True + ) + + def get_user(self, instance): + User = get_user_model() + return User.objects.get(id=instance.user_id) + + included_serializers = { + 'user': 'timed.employment.serializers.UserSerializer', + } class Meta: resource_name = 'report-users' @@ -158,7 +168,16 @@ class Meta: class ReportByTaskSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - task = IdResourceRelatedField(model=Task, read_only=True) + task = relations.SerializerMethodResourceRelatedField( + source='get_task', model=Task, read_only=True + ) + + def get_task(self, instance): + return Task.objects.get(id=instance.task_id) + + included_serializers = { + 'task': 'timed.projects.serializers.TaskSerializer', + } class Meta: resource_name = 'report-tasks' @@ -166,8 +185,16 @@ class Meta: class ReportByProjectSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - project = IdResourceRelatedField(source='task__project', model=Project, - read_only=True) + project = relations.SerializerMethodResourceRelatedField( + source='get_project', model=Project, read_only=True + ) + + def get_project(self, instance): + return Project.objects.get(id=instance.task__project_id) + + included_serializers = { + 'project': 'timed.projects.serializers.ProjectSerializer', + } class Meta: resource_name = 'report-projects' @@ -175,8 +202,16 @@ class Meta: class ReportByCustomerSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - customer = IdResourceRelatedField(source='task__project__customer', - model=Customer, read_only=True) + customer = relations.SerializerMethodResourceRelatedField( + source='get_customer', model=Customer, read_only=True + ) + + def get_customer(self, instance): + return Customer.objects.get(id=instance.task__project__customer_id) + + included_serializers = { + 'customer': 'timed.projects.serializers.CustomerSerializer', + } class Meta: resource_name = 'report-customers' diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index fad9fe649..471383af9 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -579,7 +579,10 @@ def test_report_by_user(auth_client): report = ReportFactory.create(duration=timedelta(hours=2)) url = reverse('report-by-user') - result = auth_client.get(url, data={'ordering': 'duration'}) + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'user' + }) assert result.status_code == 200 json = result.json() @@ -617,6 +620,7 @@ def test_report_by_user(auth_client): ] assert json['data'] == expected_json + assert len(json['included']) == 2 assert json['meta']['total-time'] == '05:00:00' @@ -628,7 +632,10 @@ def test_report_by_task(auth_client): ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse('report-by-task') - result = auth_client.get(url, data={'ordering': 'task__name'}) + result = auth_client.get(url, data={ + 'ordering': 'task__name', + 'include': 'task' + }) assert result.status_code == 200 json = result.json() @@ -666,6 +673,7 @@ def test_report_by_task(auth_client): ] assert json['data'] == expected_json + assert len(json['included']) == 2 assert json['meta']['total-time'] == '05:00:00' @@ -675,7 +683,10 @@ def test_report_by_project(auth_client): report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse('report-by-project') - result = auth_client.get(url, data={'ordering': 'duration'}) + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'project' + }) assert result.status_code == 200 json = result.json() @@ -713,6 +724,7 @@ def test_report_by_project(auth_client): ] assert json['data'] == expected_json + assert len(json['included']) == 2 assert json['meta']['total-time'] == '07:00:00' @@ -722,7 +734,10 @@ def test_report_by_customer(auth_client): report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse('report-by-customer') - result = auth_client.get(url, data={'ordering': 'duration'}) + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'customer' + }) assert result.status_code == 200 json = result.json() @@ -760,4 +775,5 @@ def test_report_by_customer(auth_client): ] assert json['data'] == expected_data + assert len(json['included']) == 2 assert json['meta']['total-time'] == '07:00:00' diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 280efad61..d4228390e 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -204,9 +204,9 @@ def by_month(self, request): def by_user(self, request): """Group report durations by user.""" queryset = self.filter_queryset(self.get_queryset()) - queryset = queryset.values('user') + queryset = queryset.values('user_id') queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('user')) + queryset = queryset.annotate(pk=F('user_id')) serializer = serializers.ReportByUserSerializer( queryset, many=True, context={'view': self} ) @@ -222,9 +222,9 @@ def by_user(self, request): def by_task(self, request): """Group report durations by task.""" queryset = self.filter_queryset(self.get_queryset()) - queryset = queryset.values('task') + queryset = queryset.values('task_id') queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task')) + queryset = queryset.annotate(pk=F('task_id')) serializer = serializers.ReportByTaskSerializer( queryset, many=True, context={'view': self} ) @@ -240,9 +240,9 @@ def by_task(self, request): def by_project(self, request): """Group report durations by project.""" queryset = self.filter_queryset(self.get_queryset()) - queryset = queryset.values('task__project') + queryset = queryset.values('task__project_id') queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task__project')) + queryset = queryset.annotate(pk=F('task__project_id')) serializer = serializers.ReportByProjectSerializer( queryset, many=True, context={'view': self} ) @@ -258,9 +258,9 @@ def by_project(self, request): def by_customer(self, request): """Group report durations by customer.""" queryset = self.filter_queryset(self.get_queryset()) - queryset = queryset.values('task__project__customer') + queryset = queryset.values('task__project__customer_id') queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task__project__customer')) + queryset = queryset.annotate(pk=F('task__project__customer_id')) serializer = serializers.ReportByCustomerSerializer( queryset, many=True, context={'view': self} ) From 66afc72837d332f2f405d1043a12719a04d1eaf0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 18 Oct 2017 14:06:00 +0200 Subject: [PATCH 268/980] Improve performance of report statistics end point --- timed/tracking/serializers.py | 30 ++-------- timed/tracking/tests/test_report.py | 37 +++++++------ timed/tracking/views.py | 85 +++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 47 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index b1c66f436..d71d3243f 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -150,13 +150,8 @@ class Meta: class ReportByUserSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - user = relations.SerializerMethodResourceRelatedField( - source='get_user', model=get_user_model(), read_only=True - ) - - def get_user(self, instance): - User = get_user_model() - return User.objects.get(id=instance.user_id) + user = relations.ResourceRelatedField(model=get_user_model(), + read_only=True) included_serializers = { 'user': 'timed.employment.serializers.UserSerializer', @@ -168,12 +163,7 @@ class Meta: class ReportByTaskSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - task = relations.SerializerMethodResourceRelatedField( - source='get_task', model=Task, read_only=True - ) - - def get_task(self, instance): - return Task.objects.get(id=instance.task_id) + task = relations.ResourceRelatedField(model=Task, read_only=True) included_serializers = { 'task': 'timed.projects.serializers.TaskSerializer', @@ -185,12 +175,7 @@ class Meta: class ReportByProjectSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - project = relations.SerializerMethodResourceRelatedField( - source='get_project', model=Project, read_only=True - ) - - def get_project(self, instance): - return Project.objects.get(id=instance.task__project_id) + project = relations.ResourceRelatedField(model=Project, read_only=True) included_serializers = { 'project': 'timed.projects.serializers.ProjectSerializer', @@ -202,12 +187,7 @@ class Meta: class ReportByCustomerSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) - customer = relations.SerializerMethodResourceRelatedField( - source='get_customer', model=Customer, read_only=True - ) - - def get_customer(self, instance): - return Customer.objects.get(id=instance.task__project__customer_id) + customer = relations.ResourceRelatedField(model=Customer, read_only=True) included_serializers = { 'customer': 'timed.projects.serializers.CustomerSerializer', diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 471383af9..6a0353949 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -624,7 +624,7 @@ def test_report_by_user(auth_client): assert json['meta']['total-time'] == '05:00:00' -def test_report_by_task(auth_client): +def test_report_by_task(auth_client, django_assert_num_queries): task_z = TaskFactory.create(name='Z') task_test = TaskFactory.create(name='Test') ReportFactory.create(duration=timedelta(hours=1), task=task_test) @@ -632,10 +632,11 @@ def test_report_by_task(auth_client): ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse('report-by-task') - result = auth_client.get(url, data={ - 'ordering': 'task__name', - 'include': 'task' - }) + with django_assert_num_queries(4): + result = auth_client.get(url, data={ + 'ordering': 'task__name', + 'include': 'task,task.project,task.project.customer' + }) assert result.status_code == 200 json = result.json() @@ -673,20 +674,21 @@ def test_report_by_task(auth_client): ] assert json['data'] == expected_json - assert len(json['included']) == 2 + assert len(json['included']) == 6 assert json['meta']['total-time'] == '05:00:00' -def test_report_by_project(auth_client): +def test_report_by_project(auth_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse('report-by-project') - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'project' - }) + with django_assert_num_queries(4): + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'project,project.customer' + }) assert result.status_code == 200 json = result.json() @@ -724,20 +726,21 @@ def test_report_by_project(auth_client): ] assert json['data'] == expected_json - assert len(json['included']) == 2 + assert len(json['included']) == 4 assert json['meta']['total-time'] == '07:00:00' -def test_report_by_customer(auth_client): +def test_report_by_customer(auth_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse('report-by-customer') - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'customer' - }) + with django_assert_num_queries(4): + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'customer' + }) assert result.status_code == 200 json = result.json() diff --git a/timed/tracking/views.py b/timed/tracking/views.py index d4228390e..17c430dbd 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,6 +1,7 @@ """Viewsets for the tracking app.""" import django_excel +from django.contrib.auth import get_user_model from django.db.models import F, Sum, Value from django.db.models.functions import Concat, ExtractMonth, ExtractYear from django.http import HttpResponseBadRequest @@ -9,6 +10,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from timed.projects.models import Project, Task from timed.tracking import filters, models, serializers from timed.tracking.permissions import (IsAdminUser, IsAuthenticated, IsOwner, IsReadOnly, IsUnverified) @@ -204,11 +206,29 @@ def by_month(self, request): def by_user(self, request): """Group report durations by user.""" queryset = self.filter_queryset(self.get_queryset()) + + # for performance reasons get all users needed in one go + user_ids = queryset.values_list('user_id', flat=True) + users = { + user.id: user + for user in get_user_model().objects.filter( + id__in=user_ids + ) + } + + # actual calculation of user durations queryset = queryset.values('user_id') queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('user_id')) + + # enhance entry dicts with user model instance + data = [ + {**entry, **{'user': users[entry['user_id']]}} + for entry in queryset + ] + serializer = serializers.ReportByUserSerializer( - queryset, many=True, context={'view': self} + data, many=True, context={'view': self} ) return Response(data=serializer.data) @@ -222,11 +242,29 @@ def by_user(self, request): def by_task(self, request): """Group report durations by task.""" queryset = self.filter_queryset(self.get_queryset()) + + # for performance reasons get all tasks needed in one go + task_ids = queryset.values_list('task_id', flat=True) + tasks = { + task.id: task + for task in Task.objects.filter( + id__in=task_ids + ).select_related('project', 'project__customer') + } + + # actual calculation of task durations queryset = queryset.values('task_id') queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('task_id')) + + # enhance entry dicts with task model instance + data = [ + {**entry, **{'task': tasks[entry['task_id']]}} + for entry in queryset + ] + serializer = serializers.ReportByTaskSerializer( - queryset, many=True, context={'view': self} + data, many=True, context={'view': self} ) return Response(data=serializer.data) @@ -240,11 +278,29 @@ def by_task(self, request): def by_project(self, request): """Group report durations by project.""" queryset = self.filter_queryset(self.get_queryset()) + + # for performance reasons get all projects needed in one go + project_ids = queryset.values_list('task__project_id', flat=True) + projects = { + project.id: project + for project in Project.objects.filter( + id__in=project_ids + ).select_related('customer') + } + + # actual calculation of project durations queryset = queryset.values('task__project_id') queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F('task__project_id')) + + # enhance entry dicts with project model instance + data = [ + {**entry, **{'project': projects[entry['task__project_id']]}} + for entry in queryset + ] + serializer = serializers.ReportByProjectSerializer( - queryset, many=True, context={'view': self} + data, many=True, context={'view': self} ) return Response(data=serializer.data) @@ -257,12 +313,31 @@ def by_project(self, request): ) def by_customer(self, request): """Group report durations by customer.""" + customer_id = 'task__project__customer_id' queryset = self.filter_queryset(self.get_queryset()) + + # for performance reasons get all customers needed in one go + customer_ids = queryset.values_list(customer_id, flat=True) + customers = { + project.id: project + for project in Project.objects.filter( + id__in=customer_ids + ).select_related('customer') + } + + # actual calculation of customer durations queryset = queryset.values('task__project__customer_id') queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task__project__customer_id')) + queryset = queryset.annotate(pk=F(customer_id)) + + # enhance entry dicts with customer model instance + data = [ + {**entry, **{'customer': customers[entry[customer_id]]}} + for entry in queryset + ] + serializer = serializers.ReportByCustomerSerializer( - queryset, many=True, context={'view': self} + data, many=True, context={'view': self} ) return Response(data=serializer.data) From c7f0f20cbcc374e835cafc056f1fbac44629da49 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 18 Oct 2017 15:15:17 +0200 Subject: [PATCH 269/980] Improve select related on reports statistics --- timed/tracking/views.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 721ea32f9..f263e3ccf 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -10,7 +10,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from timed.projects.models import Project, Task +from timed.projects.models import Customer, Project, Task from timed.tracking import filters, models, serializers from timed.tracking.permissions import (IsAdminUser, IsAuthenticated, IsOwner, IsReadOnly, IsUnverified) @@ -282,7 +282,13 @@ def by_task(self, request): task.id: task for task in Task.objects.filter( id__in=task_ids - ).select_related('project', 'project__customer') + ).select_related( + 'cost_center', + 'project', + 'project__billing_type', + 'project__cost_center', + 'project__customer' + ) } # actual calculation of task durations @@ -318,7 +324,7 @@ def by_project(self, request): project.id: project for project in Project.objects.filter( id__in=project_ids - ).select_related('customer') + ).select_related('cost_center', 'billing_type', 'customer') } # actual calculation of project durations @@ -352,14 +358,14 @@ def by_customer(self, request): # for performance reasons get all customers needed in one go customer_ids = queryset.values_list(customer_id, flat=True) customers = { - project.id: project - for project in Project.objects.filter( + customer.id: customer + for customer in Customer.objects.filter( id__in=customer_ids - ).select_related('customer') + ) } # actual calculation of customer durations - queryset = queryset.values('task__project__customer_id') + queryset = queryset.values(customer_id) queryset = queryset.annotate(duration=Sum('duration')) queryset = queryset.annotate(pk=F(customer_id)) From 12d9467ab6dd6bbcf61d47895b343fa0bfccd9a0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 19 Oct 2017 15:15:15 +0200 Subject: [PATCH 270/980] Move year and month statistic end point into their own view --- timed/reports/serializers.py | 38 ++++++++++++ timed/reports/tests/test_month_statistic.py | 40 ++++++++++++ timed/reports/tests/test_year_statistic.py | 38 ++++++++++++ timed/reports/urls.py | 2 + timed/reports/views.py | 38 +++++++++++- timed/tracking/serializers.py | 19 +----- timed/tracking/tests/test_report.py | 68 --------------------- timed/tracking/views.py | 43 +------------ 8 files changed, 157 insertions(+), 129 deletions(-) create mode 100644 timed/reports/serializers.py create mode 100644 timed/reports/tests/test_month_statistic.py create mode 100644 timed/reports/tests/test_year_statistic.py diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py new file mode 100644 index 000000000..4cbccf9e2 --- /dev/null +++ b/timed/reports/serializers.py @@ -0,0 +1,38 @@ +from datetime import timedelta + +from django.db.models import Sum +from django.utils.duration import duration_string +from rest_framework_json_api.serializers import DurationField, IntegerField + +from timed.serializers import DictObjectSerializer + + +class TotalTimeRootMetaMixin(object): + def get_root_meta(self, resource, many): + """Add total hours over whole result (not just page) to meta.""" + if many: + view = self.context['view'] + queryset = view.filter_queryset(view.get_queryset()) + data = queryset.aggregate(total_time=Sum('duration')) + data['total_time'] = duration_string( + data['total_time'] or timedelta(0) + ) + return data + return {} + + +class YearStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): + duration = DurationField() + year = IntegerField() + + class Meta: + resource_name = 'year-statistics' + + +class MonthStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): + duration = DurationField() + year = IntegerField() + month = IntegerField() + + class Meta: + resource_name = 'month-statistics' diff --git a/timed/reports/tests/test_month_statistic.py b/timed/reports/tests/test_month_statistic.py new file mode 100644 index 000000000..a167b1796 --- /dev/null +++ b/timed/reports/tests/test_month_statistic.py @@ -0,0 +1,40 @@ +from datetime import date, timedelta + +from django.core.urlresolvers import reverse + +from timed.tracking.factories import ReportFactory + + +def test_month_statistic_list(auth_client): + ReportFactory.create(duration=timedelta(hours=1), date=date(2016, 1, 1)) + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 4)) + ReportFactory.create(duration=timedelta(hours=2), date=date(2015, 12, 31)) + + url = reverse('month-statistic-list') + result = auth_client.get(url, data={'ordering': 'year,month'}) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'month-statistics', + 'id': '2015-12', + 'attributes': { + 'year': 2015, + 'month': 12, + 'duration': '03:00:00' + } + }, + { + 'type': 'month-statistics', + 'id': '2016-1', + 'attributes': { + 'year': 2016, + 'month': 1, + 'duration': '01:00:00' + } + } + ] + + assert json['data'] == expected_json + assert json['meta']['total-time'] == '04:00:00' diff --git a/timed/reports/tests/test_year_statistic.py b/timed/reports/tests/test_year_statistic.py new file mode 100644 index 000000000..2617eee58 --- /dev/null +++ b/timed/reports/tests/test_year_statistic.py @@ -0,0 +1,38 @@ +from datetime import date, timedelta + +from django.core.urlresolvers import reverse + +from timed.tracking.factories import ReportFactory + + +def test_year_statistic_list(auth_client): + ReportFactory.create(duration=timedelta(hours=1), date=date(2017, 1, 1)) + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) + + url = reverse('year-statistic-list') + result = auth_client.get(url, data={'ordering': 'year'}) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'year-statistics', + 'id': '2015', + 'attributes': { + 'year': 2015, + 'duration': '02:00:00' + } + }, + { + 'type': 'year-statistics', + 'id': '2017', + 'attributes': { + 'year': 2017, + 'duration': '01:00:00' + } + } + ] + + assert json['data'] == expected_json + assert json['meta']['total-time'] == '03:00:00' diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 6a8ac910f..3ac4c0cff 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -6,5 +6,7 @@ r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) r.register(r'work-report', views.WorkReportViewSet, 'work-reports') +r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') +r.register(r'month-statistics', views.MonthStatisticViewSet, 'month-statistic') urlpatterns = r.urls diff --git a/timed/reports/views.py b/timed/reports/views.py index 4c80c1a87..4881eea6e 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -5,15 +5,51 @@ from zipfile import ZipFile from django.conf import settings +from django.db.models import F, Sum, Value +from django.db.models.functions import Concat, ExtractMonth, ExtractYear from django.http import HttpResponse, HttpResponseBadRequest from ezodf import Cell, opendoc -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet +from timed.reports import serializers from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report from timed.tracking.views import ReportViewSet +class YearStatisticViewSet(ReadOnlyModelViewSet): + """Year statistics calculates total reported time per year.""" + + serializer_class = serializers.YearStatisticSerializer + filter_class = ReportFilterSet + ordering_fields = ('year', 'duration') + ordering = ('year', ) + + def get_queryset(self): + queryset = Report.objects.all() + queryset = queryset.annotate(year=ExtractYear('date')).values('year') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('year')) + return queryset + + +class MonthStatisticViewSet(ReadOnlyModelViewSet): + serializer_class = serializers.MonthStatisticSerializer + filter_class = ReportFilterSet + ordering_fields = ('year', 'month', 'duration') + ordering = ('year', 'month') + + def get_queryset(self): + queryset = Report.objects.all() + queryset = queryset.annotate( + year=ExtractYear('date'), month=ExtractMonth('date') + ) + queryset = queryset.values('year', 'month') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=Concat('year', Value('-'), 'month')) + return queryset + + class WorkReportViewSet(GenericViewSet): """ Build a ods work report of reports with given filters. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index d71d3243f..bb6d26313 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -8,7 +8,7 @@ from rest_framework_json_api import relations from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, - DurationField, IntegerField, + DurationField, ModelSerializer, SerializerMethodField, ValidationError) @@ -131,23 +131,6 @@ def get_root_meta(self, resource, many): return {} -class ReportByYearSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): - duration = DurationField(read_only=True) - year = IntegerField(read_only=True) - - class Meta: - resource_name = 'report-years' - - -class ReportByMonthSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): - duration = DurationField(read_only=True) - year = IntegerField(read_only=True) - month = IntegerField(read_only=True) - - class Meta: - resource_name = 'report-months' - - class ReportByUserSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): duration = DurationField(read_only=True) user = relations.ResourceRelatedField(model=get_user_model(), diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 0a9879622..46b457530 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -504,74 +504,6 @@ def test_report_list_no_result(admin_client): assert json['meta']['total-time'] == '00:00:00' -def test_report_by_year(auth_client): - ReportFactory.create(duration=timedelta(hours=1), date=date(2017, 1, 1)) - ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) - ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) - - url = reverse('report-by-year') - result = auth_client.get(url, data={'ordering': 'year'}) - assert result.status_code == 200 - - json = result.json() - expected_json = [ - { - 'type': 'report-years', - 'id': '2015', - 'attributes': { - 'year': 2015, - 'duration': '02:00:00' - } - }, - { - 'type': 'report-years', - 'id': '2017', - 'attributes': { - 'year': 2017, - 'duration': '01:00:00' - } - } - ] - - assert json['data'] == expected_json - assert json['meta']['total-time'] == '03:00:00' - - -def test_report_by_month(auth_client): - ReportFactory.create(duration=timedelta(hours=1), date=date(2016, 1, 1)) - ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 4)) - ReportFactory.create(duration=timedelta(hours=2), date=date(2015, 12, 31)) - - url = reverse('report-by-month') - result = auth_client.get(url, data={'ordering': 'year,month'}) - assert result.status_code == 200 - - json = result.json() - expected_json = [ - { - 'type': 'report-months', - 'id': '2015-12', - 'attributes': { - 'year': 2015, - 'month': 12, - 'duration': '03:00:00' - } - }, - { - 'type': 'report-months', - 'id': '2016-1', - 'attributes': { - 'year': 2016, - 'month': 1, - 'duration': '01:00:00' - } - } - ] - - assert json['data'] == expected_json - assert json['meta']['total-time'] == '04:00:00' - - def test_report_by_user(auth_client): user = auth_client.user ReportFactory.create(duration=timedelta(hours=1), user=user) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index f263e3ccf..1a50191f4 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -2,8 +2,7 @@ import django_excel from django.contrib.auth import get_user_model -from django.db.models import F, Sum, Value -from django.db.models.functions import Concat, ExtractMonth, ExtractYear +from django.db.models import F, Sum from django.http import HttpResponseBadRequest from rest_condition import C from rest_framework.decorators import list_route @@ -189,46 +188,6 @@ def verify_list(self, request): return Response(data={}) - @list_route( - methods=['get'], - url_path='by-year', - serializer_class=serializers.ReportByYearSerializer, - ordering_fields=('year', 'duration'), - ordering=('year', ) - ) - def by_year(self, request): - """Group report durations by year.""" - queryset = self.filter_queryset(self.get_queryset()) - queryset = queryset.annotate(year=ExtractYear('date')).values('year') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('year')) - - serializer = serializers.ReportByYearSerializer( - queryset, many=True, context={'view': self} - ) - return Response(data=serializer.data) - - @list_route( - methods=['get'], - url_path='by-month', - serializer_class=serializers.ReportByMonthSerializer, - ordering_fields=('year', 'month', 'duration'), - ordering=('year', 'month') - ) - def by_month(self, request): - """Group report durations by month.""" - queryset = self.filter_queryset(self.get_queryset()) - queryset = queryset.annotate( - year=ExtractYear('date'), month=ExtractMonth('date') - ) - queryset = queryset.values('year', 'month') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=Concat('year', Value('-'), 'month')) - serializer = serializers.ReportByMonthSerializer( - queryset, many=True, context={'view': self} - ) - return Response(data=serializer.data) - @list_route( methods=['get'], url_path='by-user', From b1478da83547eb4fea1215797d846dde8de3f26a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 19 Oct 2017 15:15:36 +0200 Subject: [PATCH 271/980] Rename endpoint work-report to work-reports for consistency --- timed/reports/tests/test_work_report.py | 6 +++--- timed/reports/urls.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 33c7217b4..920041573 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -25,7 +25,7 @@ def test_work_report_single_project(auth_client): 10, user=user, verified_by=user, task=task, date=date(2017, 8, 17) ) - url = reverse('work-reports-list') + url = reverse('work-report-list') res = auth_client.get(url, data={ 'user': auth_client.user.id, 'from_date': '2017-08-01', @@ -59,7 +59,7 @@ def test_work_report_multiple_projects(auth_client): task = TaskFactory.create(project=project) ReportFactory.create_batch(10, user=user, task=task, date=report_date) - url = reverse('work-reports-list') + url = reverse('work-report-list') res = auth_client.get(url, data={ 'user': auth_client.user.id }) @@ -81,7 +81,7 @@ def test_work_report_multiple_projects(auth_client): def test_work_report_empty(auth_client): - url = reverse('work-reports-list') + url = reverse('work-report-list') res = auth_client.get(url, data={ 'user': auth_client.user.id }) diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 3ac4c0cff..2fee3437e 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -5,7 +5,7 @@ r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'work-report', views.WorkReportViewSet, 'work-reports') +r.register(r'work-reports', views.WorkReportViewSet, 'work-report') r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') r.register(r'month-statistics', views.MonthStatisticViewSet, 'month-statistic') From 5b9a71a32ae6bc60beae1980f43325b91f0b46ba Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 19 Oct 2017 16:44:47 +0200 Subject: [PATCH 272/980] Move customer statistic in report app --- timed/reports/serializers.py | 17 +++++ .../reports/tests/test_customer_statistic.py | 57 ++++++++++++++ timed/reports/urls.py | 5 ++ timed/reports/views.py | 74 +++++++++++++++++++ timed/tracking/serializers.py | 12 --- timed/tracking/views.py | 37 ---------- 6 files changed, 153 insertions(+), 49 deletions(-) create mode 100644 timed/reports/tests/test_customer_statistic.py diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 4cbccf9e2..bf6757f1d 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -2,8 +2,10 @@ from django.db.models import Sum from django.utils.duration import duration_string +from rest_framework_json_api import relations from rest_framework_json_api.serializers import DurationField, IntegerField +from timed.projects.models import Customer from timed.serializers import DictObjectSerializer @@ -36,3 +38,18 @@ class MonthStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): class Meta: resource_name = 'month-statistics' + + +class CustomerStatisticSerializer(TotalTimeRootMetaMixin, + DictObjectSerializer): + duration = DurationField() + customer = relations.ResourceRelatedField( + source='task__project__customer', model=Customer, read_only=True + ) + + included_serializers = { + 'customer': 'timed.projects.serializers.CustomerSerializer', + } + + class Meta: + resource_name = 'customer-statistics' diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py new file mode 100644 index 000000000..74ed23569 --- /dev/null +++ b/timed/reports/tests/test_customer_statistic.py @@ -0,0 +1,57 @@ +from datetime import timedelta + +from django.core.urlresolvers import reverse + +from timed.tracking.factories import ReportFactory + + +def test_customer_statistic_list(auth_client, django_assert_num_queries): + report = ReportFactory.create(duration=timedelta(hours=1)) + ReportFactory.create(duration=timedelta(hours=2), task=report.task) + report2 = ReportFactory.create(duration=timedelta(hours=4)) + + url = reverse('customer-statistic-list') + with django_assert_num_queries(4): + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'customer' + }) + assert result.status_code == 200 + + json = result.json() + expected_data = [ + { + 'type': 'customer-statistics', + 'id': str(report.task.project.customer.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'customer': { + 'data': { + 'id': str(report.task.project.customer.id), + 'type': 'customers' + } + } + } + }, + { + 'type': 'customer-statistics', + 'id': str(report2.task.project.customer.id), + 'attributes': { + 'duration': '04:00:00' + }, + 'relationships': { + 'customer': { + 'data': { + 'id': str(report2.task.project.customer.id), + 'type': 'customers' + } + } + } + } + ] + + assert json['data'] == expected_data + assert len(json['included']) == 2 + assert json['meta']['total-time'] == '07:00:00' diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 2fee3437e..0ad9727c8 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -8,5 +8,10 @@ r.register(r'work-reports', views.WorkReportViewSet, 'work-report') r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') r.register(r'month-statistics', views.MonthStatisticViewSet, 'month-statistic') +r.register( + r'customer-statistics', + views.CustomerStatisticViewSet, + 'customer-statistic' +) urlpatterns = r.urls diff --git a/timed/reports/views.py b/timed/reports/views.py index 4881eea6e..43288cdf4 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -10,6 +10,7 @@ from django.http import HttpResponse, HttpResponseBadRequest from ezodf import Cell, opendoc from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet +from rest_framework_json_api.relations import ResourceRelatedField from timed.reports import serializers from timed.tracking.filters import ReportFilterSet @@ -17,6 +18,58 @@ from timed.tracking.views import ReportViewSet +class PrefetchDictRelatedInstanceMixin(object): + """ + Prefetch related instances represented as id in dict. + + In dict serializers or especially aggregates only an id + of a related field is part of the object. Instead of + loading each single object row by row this mixin + prefetches all resources related field in injects + it before serialization starts. + + Mixin expects the id to be the same key as the resource related + field defined in the serializer. + """ + + def get_serializer(self, data, *args, **kwargs): + many = kwargs.get('many') + if not many: + data = [data] + + # prefetch data for all related fields + prefetch_per_field = {} + serializer_class = self.get_serializer_class() + for key, value in serializer_class._declared_fields.items(): + if isinstance(value, ResourceRelatedField): + source = value.source or key + obj_ids = data.values_list(source, flat=True) + objects = { + obj.id: obj + for obj in value.model.objects.filter( + id__in=obj_ids + ) + } + prefetch_per_field[source] = objects + + # enhance entry dicts with model instances + data = [ + { + **entry, + **{ + field: objects[entry[field]] + for field, objects in prefetch_per_field.items() + } + } + for entry in data + ] + + if not many: + data = data[0] + + return super().get_serializer(data, *args, **kwargs) + + class YearStatisticViewSet(ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" @@ -34,6 +87,8 @@ def get_queryset(self): class MonthStatisticViewSet(ReadOnlyModelViewSet): + """Month statistics calculates total reported time per month.""" + serializer_class = serializers.MonthStatisticSerializer filter_class = ReportFilterSet ordering_fields = ('year', 'month', 'duration') @@ -50,6 +105,25 @@ def get_queryset(self): return queryset +class CustomerStatisticViewSet(PrefetchDictRelatedInstanceMixin, + ReadOnlyModelViewSet): + """Customer statistics calculates total reported time per customer.""" + + serializer_class = serializers.CustomerStatisticSerializer + filter_class = ReportFilterSet + ordering_fields = ('task__project__customer__name', 'duration') + ordering = ('task__project__customer__name', ) + + def get_queryset(self): + queryset = Report.objects.all() + + queryset = queryset.values('task__project__customer') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('task__project__customer')) + + return queryset + + class WorkReportViewSet(GenericViewSet): """ Build a ods work report of reports with given filters. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index bb6d26313..0a3842a34 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -168,18 +168,6 @@ class Meta: resource_name = 'report-projects' -class ReportByCustomerSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): - duration = DurationField(read_only=True) - customer = relations.ResourceRelatedField(model=Customer, read_only=True) - - included_serializers = { - 'customer': 'timed.projects.serializers.CustomerSerializer', - } - - class Meta: - resource_name = 'report-customers' - - class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 1a50191f4..0fffad337 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -302,43 +302,6 @@ def by_project(self, request): ) return Response(data=serializer.data) - @list_route( - methods=['get'], - url_path='by-customer', - serializer_class=serializers.ReportByCustomerSerializer, - ordering_fields=('task__project__customer__name', 'duration'), - ordering=('task__project__customer__name', ) - ) - def by_customer(self, request): - """Group report durations by customer.""" - customer_id = 'task__project__customer_id' - queryset = self.filter_queryset(self.get_queryset()) - - # for performance reasons get all customers needed in one go - customer_ids = queryset.values_list(customer_id, flat=True) - customers = { - customer.id: customer - for customer in Customer.objects.filter( - id__in=customer_ids - ) - } - - # actual calculation of customer durations - queryset = queryset.values(customer_id) - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F(customer_id)) - - # enhance entry dicts with customer model instance - data = [ - {**entry, **{'customer': customers[entry[customer_id]]}} - for entry in queryset - ] - - serializer = serializers.ReportByCustomerSerializer( - data, many=True, context={'view': self} - ) - return Response(data=serializer.data) - def get_queryset(self): """Select related to reduce queries. From 649f2aa5a23541a4c57372eeb34a337d84dd0f86 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 19 Oct 2017 16:44:55 +0200 Subject: [PATCH 273/980] Remove obsolete pk_key in test --- timed/tests/test_serializers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/timed/tests/test_serializers.py b/timed/tests/test_serializers.py index 3ae9358c7..4591e394c 100644 --- a/timed/tests/test_serializers.py +++ b/timed/tests/test_serializers.py @@ -11,7 +11,6 @@ class MyPkDictSerializer(DictObjectSerializer): test_nr = IntegerField() class Meta: - pk_key = 'test_nr' resource_name = 'my-resource' From 09f0d4f843dde7c3bd1e62770f82d0f91f90decd Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 20 Oct 2017 08:54:14 +0200 Subject: [PATCH 274/980] Move project statistics to its own end point --- timed/reports/serializers.py | 16 ++++- timed/reports/urls.py | 5 ++ timed/reports/views.py | 21 +++++- timed/tracking/serializers.py | 12 ---- timed/tracking/tests/test_report.py | 104 ---------------------------- timed/tracking/views.py | 36 ---------- 6 files changed, 40 insertions(+), 154 deletions(-) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index bf6757f1d..8998bd530 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -5,7 +5,7 @@ from rest_framework_json_api import relations from rest_framework_json_api.serializers import DurationField, IntegerField -from timed.projects.models import Customer +from timed.projects.models import Customer, Project from timed.serializers import DictObjectSerializer @@ -53,3 +53,17 @@ class CustomerStatisticSerializer(TotalTimeRootMetaMixin, class Meta: resource_name = 'customer-statistics' + + +class ProjectStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): + duration = DurationField() + project = relations.ResourceRelatedField( + source='task__project', model=Project, read_only=True + ) + + included_serializers = { + 'project': 'timed.projects.serializers.ProjectSerializer', + } + + class Meta: + resource_name = 'project-statistics' diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 0ad9727c8..43ba7b81d 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -13,5 +13,10 @@ views.CustomerStatisticViewSet, 'customer-statistic' ) +r.register( + r'project-statistics', + views.ProjectStatisticViewSet, + 'project-statistic' +) urlpatterns = r.urls diff --git a/timed/reports/views.py b/timed/reports/views.py index 43288cdf4..411a68699 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -48,7 +48,7 @@ def get_serializer(self, data, *args, **kwargs): obj.id: obj for obj in value.model.objects.filter( id__in=obj_ids - ) + ).select_related() } prefetch_per_field[source] = objects @@ -124,6 +124,25 @@ def get_queryset(self): return queryset +class ProjectStatisticViewSet(PrefetchDictRelatedInstanceMixin, + ReadOnlyModelViewSet): + """Project statistics calculates total reported time per project.""" + + serializer_class = serializers.ProjectStatisticSerializer + filter_class = ReportFilterSet + ordering_fields = ('task__project__name', 'duration') + ordering = ('task__project__name', ) + + def get_queryset(self): + queryset = Report.objects.all() + + queryset = queryset.values('task__project') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('task__project')) + + return queryset + + class WorkReportViewSet(GenericViewSet): """ Build a ods work report of reports with given filters. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 0a3842a34..6c0764e05 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -156,18 +156,6 @@ class Meta: resource_name = 'report-tasks' -class ReportByProjectSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): - duration = DurationField(read_only=True) - project = relations.ResourceRelatedField(model=Project, read_only=True) - - included_serializers = { - 'project': 'timed.projects.serializers.ProjectSerializer', - } - - class Meta: - resource_name = 'report-projects' - - class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 46b457530..6e115f63d 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -610,110 +610,6 @@ def test_report_by_task(auth_client, django_assert_num_queries): assert json['meta']['total-time'] == '05:00:00' -def test_report_by_project(auth_client, django_assert_num_queries): - report = ReportFactory.create(duration=timedelta(hours=1)) - ReportFactory.create(duration=timedelta(hours=2), task=report.task) - report2 = ReportFactory.create(duration=timedelta(hours=4)) - - url = reverse('report-by-project') - with django_assert_num_queries(4): - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'project,project.customer' - }) - assert result.status_code == 200 - - json = result.json() - expected_json = [ - { - 'type': 'report-projects', - 'id': str(report.task.project.id), - 'attributes': { - 'duration': '03:00:00' - }, - 'relationships': { - 'project': { - 'data': { - 'id': str(report.task.project.id), - 'type': 'projects' - } - } - } - }, - { - 'type': 'report-projects', - 'id': str(report2.task.project.id), - 'attributes': { - 'duration': '04:00:00' - }, - 'relationships': { - 'project': { - 'data': { - 'id': str(report2.task.project.id), - 'type': 'projects' - } - } - } - } - ] - - assert json['data'] == expected_json - assert len(json['included']) == 4 - assert json['meta']['total-time'] == '07:00:00' - - -def test_report_by_customer(auth_client, django_assert_num_queries): - report = ReportFactory.create(duration=timedelta(hours=1)) - ReportFactory.create(duration=timedelta(hours=2), task=report.task) - report2 = ReportFactory.create(duration=timedelta(hours=4)) - - url = reverse('report-by-customer') - with django_assert_num_queries(4): - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'customer' - }) - assert result.status_code == 200 - - json = result.json() - expected_data = [ - { - 'type': 'report-customers', - 'id': str(report.task.project.customer.id), - 'attributes': { - 'duration': '03:00:00' - }, - 'relationships': { - 'customer': { - 'data': { - 'id': str(report.task.project.customer.id), - 'type': 'customers' - } - } - } - }, - { - 'type': 'report-customers', - 'id': str(report2.task.project.customer.id), - 'attributes': { - 'duration': '04:00:00' - }, - 'relationships': { - 'customer': { - 'data': { - 'id': str(report2.task.project.customer.id), - 'type': 'customers' - } - } - } - } - ] - - assert json['data'] == expected_data - assert len(json['included']) == 2 - assert json['meta']['total-time'] == '07:00:00' - - def test_report_list_filter_cost_center(auth_client): cost_center = CostCenterFactory.create() # 1st valid case: report with task of given cost center diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 0fffad337..95210d94f 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -266,42 +266,6 @@ def by_task(self, request): ) return Response(data=serializer.data) - @list_route( - methods=['get'], - url_path='by-project', - serializer_class=serializers.ReportByProjectSerializer, - ordering_fields=('task__project__name', 'duration'), - ordering=('task__project__name', ) - ) - def by_project(self, request): - """Group report durations by project.""" - queryset = self.filter_queryset(self.get_queryset()) - - # for performance reasons get all projects needed in one go - project_ids = queryset.values_list('task__project_id', flat=True) - projects = { - project.id: project - for project in Project.objects.filter( - id__in=project_ids - ).select_related('cost_center', 'billing_type', 'customer') - } - - # actual calculation of project durations - queryset = queryset.values('task__project_id') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task__project_id')) - - # enhance entry dicts with project model instance - data = [ - {**entry, **{'project': projects[entry['task__project_id']]}} - for entry in queryset - ] - - serializer = serializers.ReportByProjectSerializer( - data, many=True, context={'view': self} - ) - return Response(data=serializer.data) - def get_queryset(self): """Select related to reduce queries. From b48ccb3171111500bd67e5460f5c07d84001c894 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 20 Oct 2017 09:04:56 +0200 Subject: [PATCH 275/980] Move task statistic end point to its own end point --- timed/reports/serializers.py | 14 +++++++- timed/reports/urls.py | 1 + timed/reports/views.py | 19 ++++++++++ timed/tracking/serializers.py | 12 ------- timed/tracking/tests/test_report.py | 54 ----------------------------- timed/tracking/views.py | 43 ----------------------- 6 files changed, 33 insertions(+), 110 deletions(-) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 8998bd530..be51b6ee4 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -5,7 +5,7 @@ from rest_framework_json_api import relations from rest_framework_json_api.serializers import DurationField, IntegerField -from timed.projects.models import Customer, Project +from timed.projects.models import Customer, Project, Task from timed.serializers import DictObjectSerializer @@ -67,3 +67,15 @@ class ProjectStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): class Meta: resource_name = 'project-statistics' + + +class TaskStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): + duration = DurationField(read_only=True) + task = relations.ResourceRelatedField(model=Task, read_only=True) + + included_serializers = { + 'task': 'timed.projects.serializers.TaskSerializer', + } + + class Meta: + resource_name = 'task-statistics' diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 43ba7b81d..ba26aac38 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -8,6 +8,7 @@ r.register(r'work-reports', views.WorkReportViewSet, 'work-report') r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') r.register(r'month-statistics', views.MonthStatisticViewSet, 'month-statistic') +r.register(r'task-statistics', views.TaskStatisticViewSet, 'task-statistic') r.register( r'customer-statistics', views.CustomerStatisticViewSet, diff --git a/timed/reports/views.py b/timed/reports/views.py index 411a68699..d7f2fe8d9 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -143,6 +143,25 @@ def get_queryset(self): return queryset +class TaskStatisticViewSet(PrefetchDictRelatedInstanceMixin, + ReadOnlyModelViewSet): + """Task statistics calculates total reported time per task.""" + + serializer_class = serializers.TaskStatisticSerializer + filter_class = ReportFilterSet + ordering_fields = ('task__name', 'duration') + ordering = ('task__name', ) + + def get_queryset(self): + queryset = Report.objects.all() + + queryset = queryset.values('task') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('task')) + + return queryset + + class WorkReportViewSet(GenericViewSet): """ Build a ods work report of reports with given filters. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 6c0764e05..69cefd25b 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -144,18 +144,6 @@ class Meta: resource_name = 'report-users' -class ReportByTaskSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): - duration = DurationField(read_only=True) - task = relations.ResourceRelatedField(model=Task, read_only=True) - - included_serializers = { - 'task': 'timed.projects.serializers.TaskSerializer', - } - - class Meta: - resource_name = 'report-tasks' - - class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 6e115f63d..61ace78b6 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -556,60 +556,6 @@ def test_report_by_user(auth_client): assert json['meta']['total-time'] == '05:00:00' -def test_report_by_task(auth_client, django_assert_num_queries): - task_z = TaskFactory.create(name='Z') - task_test = TaskFactory.create(name='Test') - ReportFactory.create(duration=timedelta(hours=1), task=task_test) - ReportFactory.create(duration=timedelta(hours=2), task=task_test) - ReportFactory.create(duration=timedelta(hours=2), task=task_z) - - url = reverse('report-by-task') - with django_assert_num_queries(4): - result = auth_client.get(url, data={ - 'ordering': 'task__name', - 'include': 'task,task.project,task.project.customer' - }) - assert result.status_code == 200 - - json = result.json() - expected_json = [ - { - 'type': 'report-tasks', - 'id': str(task_test.id), - 'attributes': { - 'duration': '03:00:00' - }, - 'relationships': { - 'task': { - 'data': { - 'id': str(task_test.id), - 'type': 'tasks' - } - } - } - }, - { - 'type': 'report-tasks', - 'id': str(task_z.id), - 'attributes': { - 'duration': '02:00:00' - }, - 'relationships': { - 'task': { - 'data': { - 'id': str(task_z.id), - 'type': 'tasks' - } - } - } - } - ] - - assert json['data'] == expected_json - assert len(json['included']) == 6 - assert json['meta']['total-time'] == '05:00:00' - - def test_report_list_filter_cost_center(auth_client): cost_center = CostCenterFactory.create() # 1st valid case: report with task of given cost center diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 95210d94f..6f51c2b3a 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -9,7 +9,6 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from timed.projects.models import Customer, Project, Task from timed.tracking import filters, models, serializers from timed.tracking.permissions import (IsAdminUser, IsAuthenticated, IsOwner, IsReadOnly, IsUnverified) @@ -224,48 +223,6 @@ def by_user(self, request): ) return Response(data=serializer.data) - @list_route( - methods=['get'], - url_path='by-task', - serializer_class=serializers.ReportByTaskSerializer, - ordering_fields=('task__name', 'duration'), - ordering=('task__name', ) - ) - def by_task(self, request): - """Group report durations by task.""" - queryset = self.filter_queryset(self.get_queryset()) - - # for performance reasons get all tasks needed in one go - task_ids = queryset.values_list('task_id', flat=True) - tasks = { - task.id: task - for task in Task.objects.filter( - id__in=task_ids - ).select_related( - 'cost_center', - 'project', - 'project__billing_type', - 'project__cost_center', - 'project__customer' - ) - } - - # actual calculation of task durations - queryset = queryset.values('task_id') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task_id')) - - # enhance entry dicts with task model instance - data = [ - {**entry, **{'task': tasks[entry['task_id']]}} - for entry in queryset - ] - - serializer = serializers.ReportByTaskSerializer( - data, many=True, context={'view': self} - ) - return Response(data=serializer.data) - def get_queryset(self): """Select related to reduce queries. From f18267eedfe401904f5af030b3a67ad63cc9dadc Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 20 Oct 2017 09:17:54 +0200 Subject: [PATCH 276/980] Move user statistics into its own end point --- timed/reports/serializers.py | 14 ++++++++ timed/reports/urls.py | 7 ++-- timed/reports/views.py | 19 +++++++++++ timed/tracking/serializers.py | 17 +--------- timed/tracking/tests/test_report.py | 52 ----------------------------- timed/tracking/views.py | 38 --------------------- 6 files changed, 38 insertions(+), 109 deletions(-) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index be51b6ee4..8575ca87b 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -1,5 +1,6 @@ from datetime import timedelta +from django.contrib.auth import get_user_model from django.db.models import Sum from django.utils.duration import duration_string from rest_framework_json_api import relations @@ -79,3 +80,16 @@ class TaskStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): class Meta: resource_name = 'task-statistics' + + +class UserStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): + duration = DurationField(read_only=True) + user = relations.ResourceRelatedField(model=get_user_model(), + read_only=True) + + included_serializers = { + 'user': 'timed.employment.serializers.UserSerializer', + } + + class Meta: + resource_name = 'user-statistics' diff --git a/timed/reports/urls.py b/timed/reports/urls.py index ba26aac38..0d1af1509 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -5,10 +5,11 @@ r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'work-reports', views.WorkReportViewSet, 'work-report') -r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') +r.register(r'work-reports', views.WorkReportViewSet, 'work-report') +r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') r.register(r'month-statistics', views.MonthStatisticViewSet, 'month-statistic') -r.register(r'task-statistics', views.TaskStatisticViewSet, 'task-statistic') +r.register(r'task-statistics', views.TaskStatisticViewSet, 'task-statistic') +r.register(r'user-statistics', views.UserStatisticViewSet, 'user-statistic') r.register( r'customer-statistics', views.CustomerStatisticViewSet, diff --git a/timed/reports/views.py b/timed/reports/views.py index d7f2fe8d9..f0f20ac52 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -162,6 +162,25 @@ def get_queryset(self): return queryset +class UserStatisticViewSet(PrefetchDictRelatedInstanceMixin, + ReadOnlyModelViewSet): + """User calculates total reported time per user.""" + + serializer_class = serializers.UserStatisticSerializer + filter_class = ReportFilterSet + ordering_fields = ('user__username', 'duration') + ordering = ('user__username', ) + + def get_queryset(self): + queryset = Report.objects.all() + + queryset = queryset.values('user') + queryset = queryset.annotate(duration=Sum('duration')) + queryset = queryset.annotate(pk=F('user')) + + return queryset + + class WorkReportViewSet(GenericViewSet): """ Build a ods work report of reports with given filters. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 69cefd25b..8b51fd382 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -5,7 +5,6 @@ from django.db.models import Sum from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ -from rest_framework_json_api import relations from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, DurationField, @@ -14,8 +13,7 @@ ValidationError) from timed.employment.models import AbsenceType, Employment, PublicHoliday -from timed.projects.models import Customer, Project, Task -from timed.serializers import DictObjectSerializer +from timed.projects.models import Task from timed.tracking import models @@ -131,19 +129,6 @@ def get_root_meta(self, resource, many): return {} -class ReportByUserSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): - duration = DurationField(read_only=True) - user = relations.ResourceRelatedField(model=get_user_model(), - read_only=True) - - included_serializers = { - 'user': 'timed.employment.serializers.UserSerializer', - } - - class Meta: - resource_name = 'report-users' - - class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): """Report serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 61ace78b6..bb07865e9 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -504,58 +504,6 @@ def test_report_list_no_result(admin_client): assert json['meta']['total-time'] == '00:00:00' -def test_report_by_user(auth_client): - user = auth_client.user - ReportFactory.create(duration=timedelta(hours=1), user=user) - ReportFactory.create(duration=timedelta(hours=2), user=user) - report = ReportFactory.create(duration=timedelta(hours=2)) - - url = reverse('report-by-user') - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'user' - }) - assert result.status_code == 200 - - json = result.json() - expected_json = [ - { - 'type': 'report-users', - 'id': str(report.user.id), - 'attributes': { - 'duration': '02:00:00' - }, - 'relationships': { - 'user': { - 'data': { - 'id': str(report.user.id), - 'type': 'users' - } - } - } - }, - { - 'type': 'report-users', - 'id': str(user.id), - 'attributes': { - 'duration': '03:00:00' - }, - 'relationships': { - 'user': { - 'data': { - 'id': str(user.id), - 'type': 'users' - } - } - } - } - ] - - assert json['data'] == expected_json - assert len(json['included']) == 2 - assert json['meta']['total-time'] == '05:00:00' - - def test_report_list_filter_cost_center(auth_client): cost_center = CostCenterFactory.create() # 1st valid case: report with task of given cost center diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 6f51c2b3a..b7eecf8dc 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,8 +1,6 @@ """Viewsets for the tracking app.""" import django_excel -from django.contrib.auth import get_user_model -from django.db.models import F, Sum from django.http import HttpResponseBadRequest from rest_condition import C from rest_framework.decorators import list_route @@ -187,42 +185,6 @@ def verify_list(self, request): return Response(data={}) - @list_route( - methods=['get'], - url_path='by-user', - serializer_class=serializers.ReportByUserSerializer, - ordering_fields=('user__username', 'duration'), - ordering=('user__username', ) - ) - def by_user(self, request): - """Group report durations by user.""" - queryset = self.filter_queryset(self.get_queryset()) - - # for performance reasons get all users needed in one go - user_ids = queryset.values_list('user_id', flat=True) - users = { - user.id: user - for user in get_user_model().objects.filter( - id__in=user_ids - ) - } - - # actual calculation of user durations - queryset = queryset.values('user_id') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('user_id')) - - # enhance entry dicts with user model instance - data = [ - {**entry, **{'user': users[entry['user_id']]}} - for entry in queryset - ] - - serializer = serializers.ReportByUserSerializer( - data, many=True, context={'view': self} - ) - return Response(data=serializer.data) - def get_queryset(self): """Select related to reduce queries. From 9d78707884dc619b51fb71e8c4a44e8a61a91aef Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 20 Oct 2017 09:19:18 +0200 Subject: [PATCH 277/980] Add statistic tests --- timed/reports/tests/test_project_statistic.py | 57 ++++++++++++++++++ timed/reports/tests/test_task_statistic.py | 60 +++++++++++++++++++ timed/reports/tests/test_user_statistic.py | 57 ++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 timed/reports/tests/test_project_statistic.py create mode 100644 timed/reports/tests/test_task_statistic.py create mode 100644 timed/reports/tests/test_user_statistic.py diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py new file mode 100644 index 000000000..f8ad7136d --- /dev/null +++ b/timed/reports/tests/test_project_statistic.py @@ -0,0 +1,57 @@ +from datetime import timedelta + +from django.core.urlresolvers import reverse + +from timed.tracking.factories import ReportFactory + + +def test_project_statistic_list(auth_client, django_assert_num_queries): + report = ReportFactory.create(duration=timedelta(hours=1)) + ReportFactory.create(duration=timedelta(hours=2), task=report.task) + report2 = ReportFactory.create(duration=timedelta(hours=4)) + + url = reverse('project-statistic-list') + with django_assert_num_queries(4): + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'project,project.customer' + }) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'project-statistics', + 'id': str(report.task.project.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'project': { + 'data': { + 'id': str(report.task.project.id), + 'type': 'projects' + } + } + } + }, + { + 'type': 'project-statistics', + 'id': str(report2.task.project.id), + 'attributes': { + 'duration': '04:00:00' + }, + 'relationships': { + 'project': { + 'data': { + 'id': str(report2.task.project.id), + 'type': 'projects' + } + } + } + } + ] + + assert json['data'] == expected_json + assert len(json['included']) == 4 + assert json['meta']['total-time'] == '07:00:00' diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py new file mode 100644 index 000000000..7073a5193 --- /dev/null +++ b/timed/reports/tests/test_task_statistic.py @@ -0,0 +1,60 @@ +from datetime import timedelta + +from django.core.urlresolvers import reverse + +from timed.projects.factories import TaskFactory +from timed.tracking.factories import ReportFactory + + +def test_task_statistic_list(auth_client, django_assert_num_queries): + task_z = TaskFactory.create(name='Z') + task_test = TaskFactory.create(name='Test') + ReportFactory.create(duration=timedelta(hours=1), task=task_test) + ReportFactory.create(duration=timedelta(hours=2), task=task_test) + ReportFactory.create(duration=timedelta(hours=2), task=task_z) + + url = reverse('task-statistic-list') + with django_assert_num_queries(4): + result = auth_client.get(url, data={ + 'ordering': 'task__name', + 'include': 'task,task.project,task.project.customer' + }) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'task-statistics', + 'id': str(task_test.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'task': { + 'data': { + 'id': str(task_test.id), + 'type': 'tasks' + } + } + } + }, + { + 'type': 'task-statistics', + 'id': str(task_z.id), + 'attributes': { + 'duration': '02:00:00' + }, + 'relationships': { + 'task': { + 'data': { + 'id': str(task_z.id), + 'type': 'tasks' + } + } + } + } + ] + + assert json['data'] == expected_json + assert len(json['included']) == 6 + assert json['meta']['total-time'] == '05:00:00' diff --git a/timed/reports/tests/test_user_statistic.py b/timed/reports/tests/test_user_statistic.py new file mode 100644 index 000000000..2d5c8f08c --- /dev/null +++ b/timed/reports/tests/test_user_statistic.py @@ -0,0 +1,57 @@ +from datetime import timedelta + +from django.core.urlresolvers import reverse + +from timed.tracking.factories import ReportFactory + + +def test_user_statistic_list(auth_client): + user = auth_client.user + ReportFactory.create(duration=timedelta(hours=1), user=user) + ReportFactory.create(duration=timedelta(hours=2), user=user) + report = ReportFactory.create(duration=timedelta(hours=2)) + + url = reverse('user-statistic-list') + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'user' + }) + assert result.status_code == 200 + + json = result.json() + expected_json = [ + { + 'type': 'user-statistics', + 'id': str(report.user.id), + 'attributes': { + 'duration': '02:00:00' + }, + 'relationships': { + 'user': { + 'data': { + 'id': str(report.user.id), + 'type': 'users' + } + } + } + }, + { + 'type': 'user-statistics', + 'id': str(user.id), + 'attributes': { + 'duration': '03:00:00' + }, + 'relationships': { + 'user': { + 'data': { + 'id': str(user.id), + 'type': 'users' + } + } + } + } + ] + + assert json['data'] == expected_json + assert len(json['included']) == 2 + assert json['meta']['total-time'] == '05:00:00' From 7aca191da5c50e1a603356189ee4352f9ebbd041 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 20 Oct 2017 13:12:35 +0200 Subject: [PATCH 278/980] Change to patched django-json-api version This includs patches to avoid query explosions and support for attribtues with the name type. --- Makefile | 4 ++-- setup.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 8196241d5..4c87f16f7 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,11 @@ help: install: ## Install production environment @pip install --upgrade pip - @pip install --upgrade . + @pip install --upgrade --process-dependency-links . install-dev: ## Install development environment @pip install --upgrade pip - @pip install --upgrade -r dev_requirements.txt -e . + @pip install --upgrade --process-dependency-links -r dev_requirements.txt -e . start: ## Start the development server @docker-compose start db diff --git a/setup.py b/setup.py index 6847325f2..8481fe1b8 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ def find_data(packages, extensions): 'django-filter==1.0.2', 'django-multiselectfield==0.1.6', 'djangorestframework>=3.6,<3.7', - 'djangorestframework-jsonapi==2.2.0', + 'djangorestframework-jsonapi==2.2.0adsy1', 'djangorestframework-jwt==1.10.0', 'psycopg2>=2.7,<2.8', 'pytz==2017.2', @@ -63,6 +63,12 @@ def find_data(packages, extensions): 'django-money==0.11.4', 'python-redmine==2.0.2', ), + dependency_links=( + # TODO: when following PR are released, change back to official release + # https://github.com/django-json-api/django-rest-framework-json-api/pull/376 + # https://github.com/django-json-api/django-rest-framework-json-api/pull/374 + 'https://github.com/adfinis-forks/django-rest-framework-json-api/tarball/timed_master#egg=djangorestframework-jsonapi-2.2.0adsy1', # noqa: E501 + ), keywords='timetracking', url='https://adfinis-sygroup.ch/', packages=find_packages(), From 4ed78a365ddf57757bd2d77c8471a3a8bc50b028 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 20 Oct 2017 15:18:09 +0200 Subject: [PATCH 279/980] Move TotalTimeRootMetaMixin to general timed serializers This mixin might be used by any end point calculation total of durations. --- timed/reports/serializers.py | 20 +------------------- timed/serializers.py | 20 ++++++++++++++++++++ timed/tracking/serializers.py | 16 +--------------- 3 files changed, 22 insertions(+), 34 deletions(-) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 8575ca87b..3b1b8f66d 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -1,27 +1,9 @@ -from datetime import timedelta - from django.contrib.auth import get_user_model -from django.db.models import Sum -from django.utils.duration import duration_string from rest_framework_json_api import relations from rest_framework_json_api.serializers import DurationField, IntegerField from timed.projects.models import Customer, Project, Task -from timed.serializers import DictObjectSerializer - - -class TotalTimeRootMetaMixin(object): - def get_root_meta(self, resource, many): - """Add total hours over whole result (not just page) to meta.""" - if many: - view = self.context['view'] - queryset = view.filter_queryset(view.get_queryset()) - data = queryset.aggregate(total_time=Sum('duration')) - data['total_time'] = duration_string( - data['total_time'] or timedelta(0) - ) - return data - return {} +from timed.serializers import DictObjectSerializer, TotalTimeRootMetaMixin class YearStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): diff --git a/timed/serializers.py b/timed/serializers.py index b30b83374..e5a1fed45 100644 --- a/timed/serializers.py +++ b/timed/serializers.py @@ -1,3 +1,7 @@ +from datetime import timedelta + +from django.db.models import Sum +from django.utils.duration import duration_string from rest_framework_json_api.serializers import Serializer @@ -36,3 +40,19 @@ def __new__(cls, instance, **kwargs): else: instance = [DictObject(**entry) for entry in instance] return super().__new__(cls, instance, **kwargs) + + +class TotalTimeRootMetaMixin(object): + duration_field = 'duration' + + def get_root_meta(self, resource, many): + """Add total hours over whole result (not just page) to meta.""" + if many: + view = self.context['view'] + queryset = view.filter_queryset(view.get_queryset()) + data = queryset.aggregate(total_time=Sum(self.duration_field)) + data['total_time'] = duration_string( + data['total_time'] or timedelta(0) + ) + return data + return {} diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 8b51fd382..ee10faed4 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -2,7 +2,6 @@ from datetime import timedelta from django.contrib.auth import get_user_model -from django.db.models import Sum from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api.relations import ResourceRelatedField @@ -14,6 +13,7 @@ from timed.employment.models import AbsenceType, Employment, PublicHoliday from timed.projects.models import Task +from timed.serializers import TotalTimeRootMetaMixin from timed.tracking import models @@ -115,20 +115,6 @@ class Meta: ] -class TotalTimeRootMetaMixin(object): - def get_root_meta(self, resource, many): - """Add total hours over whole result (not just page) to meta.""" - if many: - view = self.context['view'] - queryset = view.filter_queryset(view.get_queryset()) - data = queryset.aggregate(total_time=Sum('duration')) - data['total_time'] = duration_string( - data['total_time'] or timedelta(0) - ) - return data - return {} - - class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): """Report serializer.""" From b574dbfff54a594af8db94f603c48be01e7ac46d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 20 Oct 2017 16:13:35 +0200 Subject: [PATCH 280/980] Support detail routes on views with aggregates --- timed/reports/serializers.py | 18 +++--- .../reports/tests/test_customer_statistic.py | 15 +++++ timed/reports/tests/test_year_statistic.py | 11 ++++ timed/reports/views.py | 51 +++++++++++------ timed/serializers.py | 38 ------------- timed/tests/test_serializers.py | 55 ------------------- 6 files changed, 68 insertions(+), 120 deletions(-) delete mode 100644 timed/tests/test_serializers.py diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 3b1b8f66d..d10f368fa 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -1,12 +1,13 @@ from django.contrib.auth import get_user_model from rest_framework_json_api import relations -from rest_framework_json_api.serializers import DurationField, IntegerField +from rest_framework_json_api.serializers import (DurationField, IntegerField, + Serializer) from timed.projects.models import Customer, Project, Task -from timed.serializers import DictObjectSerializer, TotalTimeRootMetaMixin +from timed.serializers import TotalTimeRootMetaMixin -class YearStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): +class YearStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() year = IntegerField() @@ -14,7 +15,7 @@ class Meta: resource_name = 'year-statistics' -class MonthStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): +class MonthStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() year = IntegerField() month = IntegerField() @@ -23,8 +24,7 @@ class Meta: resource_name = 'month-statistics' -class CustomerStatisticSerializer(TotalTimeRootMetaMixin, - DictObjectSerializer): +class CustomerStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() customer = relations.ResourceRelatedField( source='task__project__customer', model=Customer, read_only=True @@ -38,7 +38,7 @@ class Meta: resource_name = 'customer-statistics' -class ProjectStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): +class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() project = relations.ResourceRelatedField( source='task__project', model=Project, read_only=True @@ -52,7 +52,7 @@ class Meta: resource_name = 'project-statistics' -class TaskStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): +class TaskStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField(read_only=True) task = relations.ResourceRelatedField(model=Task, read_only=True) @@ -64,7 +64,7 @@ class Meta: resource_name = 'task-statistics' -class UserStatisticSerializer(TotalTimeRootMetaMixin, DictObjectSerializer): +class UserStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField(read_only=True) user = relations.ResourceRelatedField(model=get_user_model(), read_only=True) diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 74ed23569..f0bfe3969 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -55,3 +55,18 @@ def test_customer_statistic_list(auth_client, django_assert_num_queries): assert json['data'] == expected_data assert len(json['included']) == 2 assert json['meta']['total-time'] == '07:00:00' + + +def test_customer_statistic_detail(auth_client, django_assert_num_queries): + report = ReportFactory.create(duration=timedelta(hours=1)) + + url = reverse('customer-statistic-detail', + args=[report.task.project.customer.id]) + with django_assert_num_queries(3): + result = auth_client.get(url, data={ + 'ordering': 'duration', + 'include': 'customer' + }) + assert result.status_code == 200 + json = result.json() + assert json['data']['attributes']['duration'] == '01:00:00' diff --git a/timed/reports/tests/test_year_statistic.py b/timed/reports/tests/test_year_statistic.py index 2617eee58..4b9595a01 100644 --- a/timed/reports/tests/test_year_statistic.py +++ b/timed/reports/tests/test_year_statistic.py @@ -36,3 +36,14 @@ def test_year_statistic_list(auth_client): assert json['data'] == expected_json assert json['meta']['total-time'] == '03:00:00' + + +def test_year_statistic_detail(auth_client): + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) + ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) + + url = reverse('year-statistic-detail', args=[2015]) + result = auth_client.get(url, data={'ordering': 'year'}) + assert result.status_code == 200 + json = result.json() + assert json['data']['attributes']['duration'] == '02:00:00' diff --git a/timed/reports/views.py b/timed/reports/views.py index f0f20ac52..14da57e96 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -18,13 +18,17 @@ from timed.tracking.views import ReportViewSet -class PrefetchDictRelatedInstanceMixin(object): +class AggregateQuerysetMixin(object): """ - Prefetch related instances represented as id in dict. + Add support for aggregate queryset in view. - In dict serializers or especially aggregates only an id - of a related field is part of the object. Instead of - loading each single object row by row this mixin + Wrap queryst dicts into aggregate object to support renderer + which expect attributes. + It additional prefetches related instances represented as id in + aggregate. + + In aggregates only an id of a related field is part of the object. + Instead of loading each single object row by row this mixin prefetches all resources related field in injects it before serialization starts. @@ -32,6 +36,18 @@ class PrefetchDictRelatedInstanceMixin(object): field defined in the serializer. """ + class AggregateObject(dict): + """ + Wrap dict into an object. + + All values will be accesible through attributes. Note that + keys must be valid python names for this to work. + """ + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + super().__init__(**kwargs) + def get_serializer(self, data, *args, **kwargs): many = kwargs.get('many') if not many: @@ -43,7 +59,10 @@ def get_serializer(self, data, *args, **kwargs): for key, value in serializer_class._declared_fields.items(): if isinstance(value, ResourceRelatedField): source = value.source or key - obj_ids = data.values_list(source, flat=True) + if many: + obj_ids = data.values_list(source, flat=True) + else: + obj_ids = [data[0][source]] objects = { obj.id: obj for obj in value.model.objects.filter( @@ -54,13 +73,13 @@ def get_serializer(self, data, *args, **kwargs): # enhance entry dicts with model instances data = [ - { + self.AggregateObject(**{ **entry, **{ field: objects[entry[field]] for field, objects in prefetch_per_field.items() } - } + }) for entry in data ] @@ -70,7 +89,7 @@ def get_serializer(self, data, *args, **kwargs): return super().get_serializer(data, *args, **kwargs) -class YearStatisticViewSet(ReadOnlyModelViewSet): +class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" serializer_class = serializers.YearStatisticSerializer @@ -86,7 +105,7 @@ def get_queryset(self): return queryset -class MonthStatisticViewSet(ReadOnlyModelViewSet): +class MonthStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Month statistics calculates total reported time per month.""" serializer_class = serializers.MonthStatisticSerializer @@ -105,8 +124,7 @@ def get_queryset(self): return queryset -class CustomerStatisticViewSet(PrefetchDictRelatedInstanceMixin, - ReadOnlyModelViewSet): +class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Customer statistics calculates total reported time per customer.""" serializer_class = serializers.CustomerStatisticSerializer @@ -124,8 +142,7 @@ def get_queryset(self): return queryset -class ProjectStatisticViewSet(PrefetchDictRelatedInstanceMixin, - ReadOnlyModelViewSet): +class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Project statistics calculates total reported time per project.""" serializer_class = serializers.ProjectStatisticSerializer @@ -143,8 +160,7 @@ def get_queryset(self): return queryset -class TaskStatisticViewSet(PrefetchDictRelatedInstanceMixin, - ReadOnlyModelViewSet): +class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Task statistics calculates total reported time per task.""" serializer_class = serializers.TaskStatisticSerializer @@ -162,8 +178,7 @@ def get_queryset(self): return queryset -class UserStatisticViewSet(PrefetchDictRelatedInstanceMixin, - ReadOnlyModelViewSet): +class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """User calculates total reported time per user.""" serializer_class = serializers.UserStatisticSerializer diff --git a/timed/serializers.py b/timed/serializers.py index e5a1fed45..70e3440e5 100644 --- a/timed/serializers.py +++ b/timed/serializers.py @@ -2,44 +2,6 @@ from django.db.models import Sum from django.utils.duration import duration_string -from rest_framework_json_api.serializers import Serializer - - -class DictObject(dict): - """ - Wrap dict into an object. - - All values will be accesible through attributes. Note that - keys must be valid python names for this to work. - """ - - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - super().__init__(**kwargs) - - -class DictObjectSerializer(Serializer): - """ - Serializer wrapping object into a `DictObject`. - - Adds support to serialize plain dicts with json api renderer - as such expects values to be attributes. - Note that dict needs to have a pk key to work as json api resource. - - Example: - >>> class MySerializer(DictObjectSerializer): - ... # add your fields... - ... - ... class Meta: - ... resource_name = 'my-resource' - """ - - def __new__(cls, instance, **kwargs): - if isinstance(instance, dict): - instance = DictObject(**instance) - else: - instance = [DictObject(**entry) for entry in instance] - return super().__new__(cls, instance, **kwargs) class TotalTimeRootMetaMixin(object): diff --git a/timed/tests/test_serializers.py b/timed/tests/test_serializers.py deleted file mode 100644 index 4591e394c..000000000 --- a/timed/tests/test_serializers.py +++ /dev/null @@ -1,55 +0,0 @@ -from datetime import timedelta - -import pytest -from rest_framework_json_api.serializers import DurationField, IntegerField - -from timed.serializers import DictObjectSerializer - - -class MyPkDictSerializer(DictObjectSerializer): - test_duration = DurationField() - test_nr = IntegerField() - - class Meta: - resource_name = 'my-resource' - - -@pytest.fixture -def data(): - return { - 'test_nr': 123, - 'test_duration': timedelta(hours=1), - 'invalid_field': '1234' - } - - -def test_pk_dict_serializer_single(data): - serializer = MyPkDictSerializer(data) - - expected_data = { - 'test_duration': '01:00:00', - 'test_nr': 123, - } - - assert expected_data == serializer.data - - -def test_pk_dict_serializer_many(data): - list_data = [ - data, - data - ] - serializer = MyPkDictSerializer(list_data, many=True) - - expected_data = [ - { - 'test_duration': '01:00:00', - 'test_nr': 123, - }, - { - 'test_duration': '01:00:00', - 'test_nr': 123, - }, - ] - - assert expected_data == serializer.data From c16e0211e0dbb580a405286775b51f0ad5d907e2 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 24 Oct 2017 13:28:36 +0200 Subject: [PATCH 281/980] Bump to version 0.7.0 --- timed/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/timed/__init__.py b/timed/__init__.py index 3ca80bc43..a71c5c7f1 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1,3 +1 @@ -# noqa: D104 - -__version__ = '0.6.0' +__version__ = '0.7.0' From e3d9b2d9007afed07baadf7c3d833ddcdf3ed389 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 16 Oct 2017 17:00:19 +0200 Subject: [PATCH 282/980] Move permissions module into timed module This way it is accessible by all apps. --- timed/{tracking => }/permissions.py | 0 timed/tracking/views.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename timed/{tracking => }/permissions.py (100%) diff --git a/timed/tracking/permissions.py b/timed/permissions.py similarity index 100% rename from timed/tracking/permissions.py rename to timed/permissions.py diff --git a/timed/tracking/views.py b/timed/tracking/views.py index b7eecf8dc..70df1aaa7 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -7,9 +7,9 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from timed.permissions import (IsAdminUser, IsAuthenticated, IsOwner, + IsReadOnly, IsUnverified) from timed.tracking import filters, models, serializers -from timed.tracking.permissions import (IsAdminUser, IsAuthenticated, IsOwner, - IsReadOnly, IsUnverified) class ActivityViewSet(ModelViewSet): From 843c4ce8f8b13608bb6a79abf7ffb9e38759c470 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 17 Oct 2017 15:52:02 +0200 Subject: [PATCH 283/980] Make overtime credit view fully crud aware Only superadmins may change credits. --- timed/conftest.py | 20 +++ timed/employment/filters.py | 10 ++ timed/employment/serializers.py | 6 - .../employment/tests/test_overtime_credit.py | 127 ++++++++---------- timed/employment/views.py | 38 ++++-- timed/permissions.py | 10 ++ 6 files changed, 124 insertions(+), 87 deletions(-) diff --git a/timed/conftest.py b/timed/conftest.py index e54c8ae95..4c09827f1 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -17,6 +17,26 @@ def auth_client(db): password='123qweasd', first_name='Test', last_name='User', + is_superuser=False, + is_staff=False + ) + + client = JSONAPIClient() + client.user = user + client.login('user', '123qweasd') + return client + + +@pytest.fixture +def superadmin_client(db): + """Return instance of a JSONAPIClient that is logged in as superuser.""" + user = get_user_model().objects.create_user( + username='user', + password='123qweasd', + first_name='Test', + last_name='User', + is_staff=True, + is_superuser=True ) client = JSONAPIClient() diff --git a/timed/employment/filters.py b/timed/employment/filters.py index b6c628155..6799ca573 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -43,3 +43,13 @@ class UserFilterSet(FilterSet): class Meta: model = models.User fields = ['active', 'supervisor'] + + +class OvertimeCreditFilterSet(FilterSet): + year = YearFilter(name='date') + from_date = DateFilter(name='date', lookup_expr='gte') + to_date = DateFilter(name='date', lookup_expr='lte') + + class Meta: + model = models.OvertimeCredit + fields = ['year', 'user', 'date', 'from_date', 'to_date'] diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 1a058a69f..d519264ce 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -284,13 +284,7 @@ class Meta: class OvertimeCreditSerializer(ModelSerializer): - """Overtime credit serializer.""" - - user = relations.ResourceRelatedField(read_only=True) - class Meta: - """Meta information for the overtime credit serializer.""" - model = models.OvertimeCredit fields = [ 'user', diff --git a/timed/employment/tests/test_overtime_credit.py b/timed/employment/tests/test_overtime_credit.py index 149959f7a..8c197c726 100644 --- a/timed/employment/tests/test_overtime_credit.py +++ b/timed/employment/tests/test_overtime_credit.py @@ -1,92 +1,77 @@ """Tests for the overtime credits endpoint.""" from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status -from timed.employment.factories import OvertimeCreditFactory -from timed.jsonapi_test_case import JSONAPITestCase +from timed.employment.factories import OvertimeCreditFactory, UserFactory -class OvertimeCreditTests(JSONAPITestCase): - """Tests for the overtime credits endpoint. +def test_overtime_credit_create_authenticated(auth_client): + url = reverse('overtime-credit-list') - This endpoint should be read only for normal users. - """ + result = auth_client.post(url) + assert result.status_code == status.HTTP_403_FORBIDDEN - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - self.overtime_credits = OvertimeCreditFactory.create_batch( - 5, - user=self.user - ) +def test_overtime_credit_create_superuser(superadmin_client): + url = reverse('overtime-credit-list') - OvertimeCreditFactory.create_batch(5) + data = { + 'data': { + 'type': 'overtime-credits', + 'id': None, + 'attributes': { + 'date': '2017-01-01', + 'duration': '01:00:00', + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': superadmin_client.user.id + } + } + } + } + } - def test_overtime_credit_list(self): - """Should respond with a list of overtime credits.""" - url = reverse('overtime-credit-list') + result = superadmin_client.post(url, data) + assert result.status_code == status.HTTP_201_CREATED - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK +def test_overtime_credit_get_authenticated(auth_client): + OvertimeCreditFactory.create_batch(2) + overtime_credit = OvertimeCreditFactory.create(user=auth_client.user) + url = reverse('overtime-credit-list') - result = self.result(res) + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(overtime_credit.id) - assert len(result['data']) == len(self.overtime_credits) - def test_overtime_credit_detail(self): - """Should respond with a single overtime credit.""" - overtime_credit = self.overtime_credits[0] +def test_overtime_credit_get_superuser(superadmin_client): + OvertimeCreditFactory.create_batch(2) + OvertimeCreditFactory.create(user=superadmin_client.user) + url = reverse('overtime-credit-list') - url = reverse('overtime-credit-detail', args=[ - overtime_credit.id - ]) + result = superadmin_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 3 - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK +def test_overtime_credit_get_supervisor(auth_client): + user = UserFactory.create() + auth_client.user.supervisees.add(user) - def test_overtime_credit_create(self): - """Should not be able to create a new overtime credit.""" - url = reverse('overtime-credit-list') + OvertimeCreditFactory.create_batch(1) + OvertimeCreditFactory.create(user=auth_client.user) + OvertimeCreditFactory.create(user=user) + url = reverse('overtime-credit-list') - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_overtime_credit_update(self): - """Should not be able to update an existing overtime credit.""" - overtime_credit = self.overtime_credits[0] - - url = reverse('overtime-credit-detail', args=[ - overtime_credit.id - ]) - - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_overtime_credit_delete(self): - """Should not be able delete an overtime credit.""" - overtime_credit = self.overtime_credits[0] - - url = reverse('overtime-credit-detail', args=[ - overtime_credit.id - ]) - - noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 2 diff --git a/timed/employment/views.py b/timed/employment/views.py index 65fddedc9..b9300cedd 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -1,10 +1,13 @@ """Viewsets for the employment app.""" from django.contrib.auth import get_user_model +from django.db.models import Q +from rest_condition import C from rest_framework import mixins, viewsets -from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from timed.employment import filters, models, serializers +from timed.permissions import IsAuthenticated, IsReadOnly, IsSuperUser class UserViewSet(mixins.RetrieveModelMixin, @@ -80,19 +83,34 @@ class AbsenceTypeViewSet(ReadOnlyModelViewSet): ordering = ('name',) -class OvertimeCreditViewSet(ReadOnlyModelViewSet): +class OvertimeCreditViewSet(ModelViewSet): """Absence type view set.""" + filter_class = filters.OvertimeCreditFilterSet serializer_class = serializers.OvertimeCreditSerializer + permission_classes = [ + # super user can add/read overtime credits + C(IsAuthenticated) & C(IsSuperUser) | + # user may only read filtered results + C(IsAuthenticated) & C(IsReadOnly) + ] def get_queryset(self): - """Filter the queryset by the user of the request. + """ + Get queryset of overtime credits. - :return: The filtered overtime credits - :rtype: QuerySet + Following rules apply: + 1. super user may see all + 2. user may see credits of all its supervisors and self + 3. user may only see its own credit """ - return models.OvertimeCredit.objects.select_related( - 'user' - ).filter( - user=self.request.user - ) + user = self.request.user + + queryset = models.OvertimeCredit.objects.select_related('user') + + if not user.is_superuser: + queryset = queryset.filter( + Q(user=user) | Q(user__supervisors=user) + ) + + return queryset diff --git a/timed/permissions.py b/timed/permissions.py index 96ae05257..ebe99ceec 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -48,3 +48,13 @@ class IsAdminUser(IsAdminUser): def has_object_permission(self, request, view, obj): return self.has_permission(request, view) + + +class IsSuperUser(BasePermission): + """Allows access only to superuser.""" + + def has_permission(self, request, view): + return request.user and request.user.is_superuser + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) From 2079fa5d80c2a5e57a09bf7cf59cfc9517982bd9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 18 Oct 2017 17:26:16 +0200 Subject: [PATCH 284/980] Add absence credit end point --- timed/employment/filters.py | 12 +++ timed/employment/serializers.py | 3 - timed/employment/tests/test_absence_credit.py | 84 +++++++++++++++++++ timed/employment/urls.py | 1 + timed/employment/views.py | 33 ++++++++ timed/permissions.py | 2 +- 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 timed/employment/tests/test_absence_credit.py diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 6799ca573..daa95bf1b 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -53,3 +53,15 @@ class OvertimeCreditFilterSet(FilterSet): class Meta: model = models.OvertimeCredit fields = ['year', 'user', 'date', 'from_date', 'to_date'] + + +class AbsenceCreditFilterSet(FilterSet): + year = YearFilter(name='date') + from_date = DateFilter(name='date', lookup_expr='gte') + to_date = DateFilter(name='date', lookup_expr='lte') + + class Meta: + model = models.AbsenceCredit + fields = [ + 'year', 'user', 'date', 'from_date', 'to_date', 'absence_type' + ] diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index d519264ce..6d1a30593 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -263,9 +263,6 @@ class Meta: class AbsenceCreditSerializer(ModelSerializer): """Absence credit serializer.""" - absence_type = relations.ResourceRelatedField(read_only=True) - user = relations.ResourceRelatedField(read_only=True) - included_serializers = { 'absence_type': 'timed.employment.serializers.AbsenceTypeSerializer' } diff --git a/timed/employment/tests/test_absence_credit.py b/timed/employment/tests/test_absence_credit.py new file mode 100644 index 000000000..461ffc757 --- /dev/null +++ b/timed/employment/tests/test_absence_credit.py @@ -0,0 +1,84 @@ +from django.core.urlresolvers import reverse +from rest_framework import status + +from timed.employment.factories import (AbsenceCreditFactory, + AbsenceTypeFactory, UserFactory) + + +def test_absence_credit_create_authenticated(auth_client): + url = reverse('absence-credit-list') + + result = auth_client.post(url) + assert result.status_code == status.HTTP_403_FORBIDDEN + + +def test_absence_credit_create_superuser(superadmin_client): + absence_type = AbsenceTypeFactory.create() + + url = reverse('absence-credit-list') + + data = { + 'data': { + 'type': 'absence-credits', + 'id': None, + 'attributes': { + 'date': '2017-01-01', + 'duration': '01:00:00', + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': superadmin_client.user.id + } + }, + 'absence_type': { + 'data': { + 'type': 'absence-types', + 'id': absence_type.id + } + } + } + } + } + + result = superadmin_client.post(url, data) + assert result.status_code == status.HTTP_201_CREATED + + +def test_absence_credit_get_authenticated(auth_client): + AbsenceCreditFactory.create_batch(2) + absence_credit = AbsenceCreditFactory.create(user=auth_client.user) + url = reverse('absence-credit-list') + + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(absence_credit.id) + + +def test_absence_credit_get_superuser(superadmin_client): + AbsenceCreditFactory.create_batch(2) + AbsenceCreditFactory.create(user=superadmin_client.user) + url = reverse('absence-credit-list') + + result = superadmin_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 3 + + +def test_absence_credit_get_supervisor(auth_client): + user = UserFactory.create() + auth_client.user.supervisees.add(user) + + AbsenceCreditFactory.create_batch(1) + AbsenceCreditFactory.create(user=auth_client.user) + AbsenceCreditFactory.create(user=user) + url = reverse('absence-credit-list') + + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 2 diff --git a/timed/employment/urls.py b/timed/employment/urls.py index e2a97a6b9..0a7a4775d 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -13,5 +13,6 @@ r.register(r'public-holidays', views.PublicHolidayViewSet, 'public-holiday') r.register(r'absence-types', views.AbsenceTypeViewSet, 'absence-type') r.register(r'overtime-credits', views.OvertimeCreditViewSet, 'overtime-credit') +r.register(r'absence-credits', views.AbsenceCreditViewSet, 'absence-credit') urlpatterns = r.urls diff --git a/timed/employment/views.py b/timed/employment/views.py index b9300cedd..976d56f02 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -83,6 +83,39 @@ class AbsenceTypeViewSet(ReadOnlyModelViewSet): ordering = ('name',) +class AbsenceCreditViewSet(ModelViewSet): + """Absence type view set.""" + + filter_class = filters.AbsenceCreditFilterSet + serializer_class = serializers.AbsenceCreditSerializer + permission_classes = [ + # super user can add/read absence credits + C(IsAuthenticated) & C(IsSuperUser) | + # user may only read filtered results + C(IsAuthenticated) & C(IsReadOnly) + ] + + def get_queryset(self): + """ + Get queryset of absence credits. + + Following rules apply: + 1. super user may see all + 2. user may see credits of all its supervisors and self + 3. user may only see its own credit + """ + user = self.request.user + + queryset = models.AbsenceCredit.objects.select_related('user') + + if not user.is_superuser: + queryset = queryset.filter( + Q(user=user) | Q(user__supervisors=user) + ) + + return queryset + + class OvertimeCreditViewSet(ModelViewSet): """Absence type view set.""" diff --git a/timed/permissions.py b/timed/permissions.py index ebe99ceec..4d84dc743 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -56,5 +56,5 @@ class IsSuperUser(BasePermission): def has_permission(self, request, view): return request.user and request.user.is_superuser - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj): # pragma: todo cover return self.has_permission(request, view) From 3736afad5000859ed6fb234464664185fd01e802 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 19 Oct 2017 14:12:52 +0200 Subject: [PATCH 285/980] Reduce queries when calculating worktime --- timed/employment/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 4ad940b7c..d1d989879 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -337,14 +337,14 @@ def calculate_worktime(self, start, end): expected_worktime = self.worktime_per_day * (workdays - holidays) overtime_credit_data = OvertimeCredit.objects.filter( - user=self.user, + user=self.user_id, date__gte=start, date__lte=end ).aggregate(total_duration=Sum('duration')) overtime_credit = overtime_credit_data['total_duration'] or timedelta() reported_worktime_data = Report.objects.filter( - user=self.user, + user=self.user_id, date__gte=start, date__lte=end ).aggregate(duration_total=Sum('duration')) @@ -355,7 +355,7 @@ def calculate_worktime(self, start, end): absences = sum([ absence.calculate_duration(self) for absence in Absence.objects.filter( - user=self.user, + user=self.user_id, date__gte=start, date__lte=end, ) @@ -418,7 +418,8 @@ def calculate_worktime(self, start, end): :returns: tuple of 3 values reported, expected and delta in given time frame """ - employments = Employment.objects.for_user(self, start, end) + employments = Employment.objects.for_user( + self, start, end).select_related('location') balances = [ employment.calculate_worktime(start, end) From 03e5617c1df1cec8873fe9141799af869e8d72a7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 23 Oct 2017 11:16:50 +0200 Subject: [PATCH 286/980] Move AggregateQuerysetMixin into generic module --- timed/mixins.py | 72 +++++++++++++++++++++++++++++++++++++++++ timed/reports/views.py | 73 +----------------------------------------- 2 files changed, 73 insertions(+), 72 deletions(-) create mode 100644 timed/mixins.py diff --git a/timed/mixins.py b/timed/mixins.py new file mode 100644 index 000000000..f0e913e00 --- /dev/null +++ b/timed/mixins.py @@ -0,0 +1,72 @@ +from rest_framework_json_api.relations import ResourceRelatedField + + +class AggregateQuerysetMixin(object): + """ + Add support for aggregate queryset in view. + + Wrap queryst dicts into aggregate object to support renderer + which expect attributes. + It additional prefetches related instances represented as id in + aggregate. + + In aggregates only an id of a related field is part of the object. + Instead of loading each single object row by row this mixin + prefetches all resources related field in injects + it before serialization starts. + + Mixin expects the id to be the same key as the resource related + field defined in the serializer. + """ + + class AggregateObject(dict): + """ + Wrap dict into an object. + + All values will be accesible through attributes. Note that + keys must be valid python names for this to work. + """ + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + super().__init__(**kwargs) + + def get_serializer(self, data, *args, **kwargs): + many = kwargs.get('many') + if not many: + data = [data] + + # prefetch data for all related fields + prefetch_per_field = {} + serializer_class = self.get_serializer_class() + for key, value in serializer_class._declared_fields.items(): + if isinstance(value, ResourceRelatedField): + source = value.source or key + if many: + obj_ids = data.values_list(source, flat=True) + else: + obj_ids = [data[0][source]] + objects = { + obj.id: obj + for obj in value.model.objects.filter( + id__in=obj_ids + ).select_related() + } + prefetch_per_field[source] = objects + + # enhance entry dicts with model instances + data = [ + self.AggregateObject(**{ + **entry, + **{ + field: objects[entry[field]] + for field, objects in prefetch_per_field.items() + } + }) + for entry in data + ] + + if not many: + data = data[0] + + return super().get_serializer(data, *args, **kwargs) diff --git a/timed/reports/views.py b/timed/reports/views.py index 14da57e96..29c7f0f50 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -10,85 +10,14 @@ from django.http import HttpResponse, HttpResponseBadRequest from ezodf import Cell, opendoc from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet -from rest_framework_json_api.relations import ResourceRelatedField +from timed.mixins import AggregateQuerysetMixin from timed.reports import serializers from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report from timed.tracking.views import ReportViewSet -class AggregateQuerysetMixin(object): - """ - Add support for aggregate queryset in view. - - Wrap queryst dicts into aggregate object to support renderer - which expect attributes. - It additional prefetches related instances represented as id in - aggregate. - - In aggregates only an id of a related field is part of the object. - Instead of loading each single object row by row this mixin - prefetches all resources related field in injects - it before serialization starts. - - Mixin expects the id to be the same key as the resource related - field defined in the serializer. - """ - - class AggregateObject(dict): - """ - Wrap dict into an object. - - All values will be accesible through attributes. Note that - keys must be valid python names for this to work. - """ - - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - super().__init__(**kwargs) - - def get_serializer(self, data, *args, **kwargs): - many = kwargs.get('many') - if not many: - data = [data] - - # prefetch data for all related fields - prefetch_per_field = {} - serializer_class = self.get_serializer_class() - for key, value in serializer_class._declared_fields.items(): - if isinstance(value, ResourceRelatedField): - source = value.source or key - if many: - obj_ids = data.values_list(source, flat=True) - else: - obj_ids = [data[0][source]] - objects = { - obj.id: obj - for obj in value.model.objects.filter( - id__in=obj_ids - ).select_related() - } - prefetch_per_field[source] = objects - - # enhance entry dicts with model instances - data = [ - self.AggregateObject(**{ - **entry, - **{ - field: objects[entry[field]] - for field, objects in prefetch_per_field.items() - } - }) - for entry in data - ] - - if not many: - data = data[0] - - return super().get_serializer(data, *args, **kwargs) - - class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" From 09456d8e41b2809f862d43d9ed0b7bbae128d475 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 23 Oct 2017 11:28:00 +0200 Subject: [PATCH 287/980] Update to pytest-freezegun 0.2.0 --- dev_requirements.txt | 2 +- timed/redmine/tests/test_redmine_report.py | 4 ---- timed/reports/tests/test_notify_changed_employments.py | 2 -- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 1f1905255..0a902a126 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -17,4 +17,4 @@ pytest-mock==1.6.2 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af -pytest-freezegun==0.1.0 +pytest-freezegun==0.2.0 diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 6183356e3..b1f18dda8 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -1,4 +1,3 @@ -import pytest from django.core.management import call_command from redminelib.exceptions import ResourceNotFoundError @@ -7,7 +6,6 @@ from timed.tracking.factories import ReportFactory -@pytest.mark.freeze_time def test_redmine_report(db, freezer, mocker): """ Test redmine report. @@ -47,7 +45,6 @@ def test_redmine_report(db, freezer, mocker): issue.save.assert_called_once_with() -@pytest.mark.freeze_time def test_redmine_report_no_estimated_time(db, freezer, mocker): redmine_instance = mocker.MagicMock() issue = mocker.MagicMock() @@ -68,7 +65,6 @@ def test_redmine_report_no_estimated_time(db, freezer, mocker): issue.save.assert_called_once_with() -@pytest.mark.freeze_time def test_redmine_report_invalid_issue(db, freezer, mocker, capsys): """Test case when issue is not available.""" redmine_instance = mocker.MagicMock() diff --git a/timed/reports/tests/test_notify_changed_employments.py b/timed/reports/tests/test_notify_changed_employments.py index 3b97ed7b5..5a5fb3489 100644 --- a/timed/reports/tests/test_notify_changed_employments.py +++ b/timed/reports/tests/test_notify_changed_employments.py @@ -1,12 +1,10 @@ from datetime import date -import pytest from django.core.management import call_command from timed.employment.factories import EmploymentFactory -@pytest.mark.freeze_time def test_notify_changed_employments(db, mailoutbox, freezer): email = 'test@example.net' From d85c2ba9428572f9ee5403273279032fc633dc20 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 23 Oct 2017 14:57:09 +0200 Subject: [PATCH 288/980] Move worktime calculation into its own end point --- timed/employment/filters.py | 8 + timed/employment/models.py | 2 +- timed/employment/serializers.py | 43 ++-- timed/employment/tests/test_user.py | 119 +---------- .../employment/tests/test_worktime_balance.py | 188 ++++++++++++++++++ timed/employment/urls.py | 5 + timed/employment/views.py | 68 ++++++- timed/tracking/models.py | 2 +- 8 files changed, 290 insertions(+), 145 deletions(-) create mode 100644 timed/employment/tests/test_worktime_balance.py diff --git a/timed/employment/filters.py b/timed/employment/filters.py index daa95bf1b..20600d416 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -65,3 +65,11 @@ class Meta: fields = [ 'year', 'user', 'date', 'from_date', 'to_date', 'absence_type' ] + + +class WorktimeBalanceFilterSet(FilterSet): + user = NumberFilter(name='id') + + class Meta: + model = models.User + fields = ['user'] diff --git a/timed/employment/models.py b/timed/employment/models.py index d1d989879..e8d33f3eb 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -358,7 +358,7 @@ def calculate_worktime(self, start, end): user=self.user_id, date__gte=start, date__lte=end, - ) + ).select_related('type') ], timedelta()) reported = reported_worktime + absences + overtime_credit diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 6d1a30593..bbaf2af62 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -5,8 +5,9 @@ from django.contrib.auth import get_user_model from django.utils.duration import duration_string from rest_framework.exceptions import PermissionDenied -from rest_framework_json_api import relations +from rest_framework_json_api import relations, serializers from rest_framework_json_api.serializers import (IntegerField, ModelSerializer, + Serializer, SerializerMethodField) from timed.employment import models @@ -18,7 +19,6 @@ class UserSerializer(ModelSerializer): employments = relations.ResourceRelatedField(many=True, read_only=True) - worktime_balance = SerializerMethodField() user_absence_types = relations.SerializerMethodResourceRelatedField( source='get_user_absence_types', model=models.UserAbsenceType, @@ -44,26 +44,6 @@ def get_user_absence_types(self, instance): start, end) - def get_worktime(self, user, start=None, end=None): - end = end or date.today() - start = date(end.year, 1, 1) - - balance_tuple = user.calculate_worktime(start, end) - return balance_tuple - - def get_worktime_balance(self, instance): - """Format the worktime balance. - - :return: The formatted worktime balance. - :rtype: str - """ - request = self.context.get('request') - until = request and request.query_params.get('until') - end_date = until and datetime.strptime(until, '%Y-%m-%d').date() - - _, _, balance = self.get_worktime(instance, None, end_date) - return duration_string(balance) - def validate(self, data): user = self.context['request'].user @@ -94,7 +74,6 @@ class Meta: 'last_name', 'email', 'employments', - 'worktime_balance', 'is_staff', 'is_superuser', 'is_active', @@ -114,6 +93,24 @@ class Meta: ] +class WorktimeBalanceSerializer(Serializer): + date = serializers.DateField() + balance = SerializerMethodField() + user = relations.ResourceRelatedField( + model=get_user_model(), read_only=True, source='id' + ) + + def get_balance(self, instance): + start = date(instance.date.year, 1, 1) + + # id is mapped to user instance + _, _, balance = instance.id.calculate_worktime(start, instance.date) + return duration_string(balance) + + class Meta: + resource_name = 'worktime-balances' + + class EmploymentSerializer(ModelSerializer): """Employment serializer.""" diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 08abf16d7..464d7f8ad 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -2,17 +2,14 @@ from datetime import date, timedelta -from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from django.utils.duration import duration_string from rest_framework import status from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) from timed.employment.factories import (AbsenceCreditFactory, AbsenceTypeFactory, EmploymentFactory, - OvertimeCreditFactory, - PublicHolidayFactory, UserFactory) + UserFactory) from timed.jsonapi_test_case import JSONAPITestCase from timed.tracking.factories import AbsenceFactory, ReportFactory @@ -138,26 +135,6 @@ def test_user_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - def test_user_without_employment(self): - user = get_user_model().objects.create_user(username='test', - password='1234qwer') - self.client.login('test', '1234qwer') - - url = reverse('user-detail', args=[ - user.id - ]) - - res = self.client.get(url) - - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - assert int(result['data']['id']) == user.id - assert result['data']['attributes']['worktime-balance'] == ( - '00:00:00' - ) - def test_user_absence_types(self): absence_type = AbsenceTypeFactory.create() @@ -259,100 +236,6 @@ def test_user_absence_types_fill_worktime(self): assert inc[0]['attributes']['used-duration'] == '06:00:00' -def test_user_worktime_balance(auth_client): - """Should calculate correct worktime balances.""" - # Calculate over one week - start_date = date(2017, 3, 19) - end_date = date(2017, 3, 26) - - user = auth_client.user - employment = EmploymentFactory.create( - user=user, - start_date=start_date, - worktime_per_day=timedelta(hours=8, minutes=30), - end_date=date(2017, 3, 23) - ) - employment_short = EmploymentFactory.create( - user=user, - start_date=date(2017, 3, 24), - worktime_per_day=timedelta(hours=8), - end_date=None - ) - - # Overtime credit of 10 hours - OvertimeCreditFactory.create( - user=user, - date=start_date, - duration=timedelta(hours=10, minutes=30) - ) - - # One public holiday during workdays - PublicHolidayFactory.create( - date=start_date, - location=employment.location - ) - # One public holiday on weekend - PublicHolidayFactory.create( - date=start_date + timedelta(days=1), - location=employment.location - ) - - url = reverse('user-detail', args=[ - user.id - ]) - - res = auth_client.get('{0}?until={1}'.format( - url, - end_date.strftime('%Y-%m-%d') - )) - - result = res.json() - - # Calculated result: - # 4 workdays 8.5 hours, 1 workday 8 hours, minus one holiday 8.5 - # minutes 10.5 hours overtime credit - expected_worktime = ( - 1 * employment_short.worktime_per_day + - 3 * employment.worktime_per_day - - timedelta(hours=10, minutes=30) - ) - - assert ( - result['data']['attributes']['worktime-balance'] == - duration_string(timedelta() - expected_worktime) - ) - - # 2x 10 hour reported worktime - ReportFactory.create( - user=user, - date=start_date + timedelta(days=3), - duration=timedelta(hours=10) - ) - - ReportFactory.create( - user=user, - date=start_date + timedelta(days=4), - duration=timedelta(hours=10) - ) - - AbsenceFactory.create( - user=user, - date=start_date + timedelta(days=5) - ) - - res2 = auth_client.get('{0}?until={1}'.format( - url, - end_date.strftime('%Y-%m-%d') - )) - - result2 = res2.json() - - assert ( - result2['data']['attributes']['worktime-balance'] == - duration_string(timedelta(hours=28) - expected_worktime) - ) - - def test_user_supervisor_filter(auth_client): """Should filter users by supervisor.""" supervisees = UserFactory.create_batch(5) diff --git a/timed/employment/tests/test_worktime_balance.py b/timed/employment/tests/test_worktime_balance.py new file mode 100644 index 000000000..e8a08b51a --- /dev/null +++ b/timed/employment/tests/test_worktime_balance.py @@ -0,0 +1,188 @@ +from datetime import date, timedelta + +from django.core.urlresolvers import reverse +from django.utils.duration import duration_string +from rest_framework import status + +from timed.employment.factories import (EmploymentFactory, + OvertimeCreditFactory, + PublicHolidayFactory, UserFactory) +from timed.tracking.factories import AbsenceFactory, ReportFactory + + +def test_worktime_balance_create(auth_client): + url = reverse('worktime-balance-list') + + result = auth_client.post(url) + assert result.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +def test_worktime_balance_no_employment(auth_client, + django_assert_num_queries): + url = reverse('worktime-balance-list') + + with django_assert_num_queries(4): + result = auth_client.get(url, data={ + 'user': auth_client.user.id, + 'date': '2017-01-01', + }) + + assert result.status_code == status.HTTP_200_OK + + json = result.json() + assert len(json['data']) == 1 + data = json['data'][0] + assert data['id'] == '{0}_2017-01-01'.format(auth_client.user.id) + assert data['attributes']['balance'] == '00:00:00' + + +def test_worktime_balance_with_employments(auth_client, + django_assert_num_queries): + # Calculate over one week + start_date = date(2017, 3, 19) + end_date = date(2017, 3, 26) + + employment = EmploymentFactory.create( + user=auth_client.user, + start_date=start_date, + worktime_per_day=timedelta(hours=8, minutes=30), + end_date=date(2017, 3, 23) + ) + EmploymentFactory.create( + user=auth_client.user, + start_date=date(2017, 3, 24), + worktime_per_day=timedelta(hours=8), + end_date=None + ) + + # Overtime credit of 10 hours + OvertimeCreditFactory.create( + user=auth_client.user, + date=start_date, + duration=timedelta(hours=10, minutes=30) + ) + + # One public holiday during workdays + PublicHolidayFactory.create( + date=start_date, + location=employment.location + ) + # One public holiday on weekend + PublicHolidayFactory.create( + date=start_date + timedelta(days=1), + location=employment.location + ) + + # 2x 10 hour reported worktime + ReportFactory.create( + user=auth_client.user, + date=start_date + timedelta(days=3), + duration=timedelta(hours=10) + ) + + ReportFactory.create( + user=auth_client.user, + date=start_date + timedelta(days=4), + duration=timedelta(hours=10) + ) + + # one absence + AbsenceFactory.create( + user=auth_client.user, + date=start_date + timedelta(days=5) + ) + + url = reverse('worktime-balance-detail', args=[ + '{0}_{1}'.format(auth_client.user.id, end_date.strftime('%Y-%m-%d')) + ]) + + with django_assert_num_queries(12): + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + + # 4 workdays 8.5 hours, 1 workday 8 hours, minus one holiday 8.5 + # minutes 10.5 hours overtime credit + expected_worktime = timedelta(hours=23) + + # 2 x 10 reports hours + 1 absence of 8 hours + expected_reported = timedelta(hours=28) + + json = result.json() + assert json['data']['attributes']['balance'] == ( + duration_string(expected_reported - expected_worktime) + ) + + +def test_worktime_balance_invalid_pk(auth_client): + url = reverse('worktime-balance-detail', args=['invalid']) + + result = auth_client.get(url) + assert result.status_code == status.HTTP_404_NOT_FOUND + + +def test_worktime_balance_no_date(auth_client): + url = reverse('worktime-balance-list') + + result = auth_client.get(url) + assert result.status_code == status.HTTP_400_BAD_REQUEST + + +def test_worktime_balance_invalid_date(auth_client): + url = reverse('worktime-balance-list') + + result = auth_client.get(url, data={'date': 'invalid'}) + assert result.status_code == status.HTTP_400_BAD_REQUEST + + +def test_user_worktime_list_superuser(auth_client): + auth_client.user.is_superuser = True + auth_client.user.save() + supervisee = UserFactory.create() + UserFactory.create() + auth_client.user.supervisees.add(supervisee) + + url = reverse('worktime-balance-list') + + result = auth_client.get(url, data={ + 'date': '2017-01-01', + }) + + assert result.status_code == status.HTTP_200_OK + + json = result.json() + assert len(json['data']) == 3 + + +def test_worktime_balance_list_supervisor(auth_client): + supervisee = UserFactory.create() + UserFactory.create() + auth_client.user.supervisees.add(supervisee) + + url = reverse('worktime-balance-list') + + result = auth_client.get(url, data={ + 'date': '2017-01-01', + }) + + assert result.status_code == status.HTTP_200_OK + + json = result.json() + assert len(json['data']) == 2 + + +def test_worktime_balance_list_filter_user(auth_client): + supervisee = UserFactory.create() + UserFactory.create() + auth_client.user.supervisees.add(supervisee) + + url = reverse('worktime-balance-list') + + result = auth_client.get(url, data={ + 'date': '2017-01-01', + 'user': supervisee.id + }) + + assert result.status_code == status.HTTP_200_OK + + json = result.json() + assert len(json['data']) == 1 diff --git a/timed/employment/urls.py b/timed/employment/urls.py index 0a7a4775d..f653f963c 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -14,5 +14,10 @@ r.register(r'absence-types', views.AbsenceTypeViewSet, 'absence-type') r.register(r'overtime-credits', views.OvertimeCreditViewSet, 'overtime-credit') r.register(r'absence-credits', views.AbsenceCreditViewSet, 'absence-credit') +r.register( + r'worktime-balances', + views.WorktimeBalanceViewSet, + 'worktime-balance' +) urlpatterns = r.urls diff --git a/timed/employment/views.py b/timed/employment/views.py index 976d56f02..bd658e9bc 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -1,12 +1,16 @@ """Viewsets for the employment app.""" +import datetime from django.contrib.auth import get_user_model -from django.db.models import Q +from django.db.models import CharField, DateField, Q, Value +from django.db.models.functions import Concat +from django.utils.translation import ugettext_lazy as _ from rest_condition import C -from rest_framework import mixins, viewsets +from rest_framework import exceptions, mixins, viewsets from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from timed.employment import filters, models, serializers +from timed.mixins import AggregateQuerysetMixin from timed.permissions import IsAuthenticated, IsReadOnly, IsSuperUser @@ -29,6 +33,66 @@ def get_queryset(self): return get_user_model().objects.prefetch_related('employments') +class WorktimeBalanceViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): + """Calculate worktime for different user on different dates.""" + + serializer_class = serializers.WorktimeBalanceSerializer + filter_class = filters.WorktimeBalanceFilterSet + + def _extract_date(self): + """ + Extract date from request. + + In detail route extract it from pk and it list + from query params. + """ + pk = self.request.parser_context['kwargs'].get('pk') + + # detail case + if pk is not None: + try: + return datetime.datetime.strptime( + pk.split('_')[1], '%Y-%m-%d' + ) + + except (ValueError, TypeError, IndexError): + raise exceptions.NotFound() + + # list case + try: + return datetime.datetime.strptime( + self.request.query_params.get('date'), + '%Y-%m-%d' + ).date() + except ValueError: + raise exceptions.ParseError(_('Date is invalid')) + except TypeError: + raise exceptions.ParseError(_('Date filter needs to be set')) + + def get_queryset(self): + date = self._extract_date() + user = self.request.user + queryset = get_user_model().objects.values('id') + queryset = queryset.annotate( + date=Value(date, DateField()), + ) + queryset = queryset.annotate( + pk=Concat( + 'id', + Value('_'), + 'date', + output_field=CharField() + ) + ) + + if not user.is_superuser: + queryset = queryset.filter( + Q(id=user.id) | Q(supervisors=user) + ) + + return queryset + + class EmploymentViewSet(ReadOnlyModelViewSet): """Employment view set.""" diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 631be19e2..4183a9ec1 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -174,7 +174,7 @@ def calculate_duration(self, employment): if not self.type.fill_worktime: return employment.worktime_per_day - reports = Report.objects.filter(date=self.date, user=self.user) + reports = Report.objects.filter(date=self.date, user=self.user_id) data = reports.aggregate(reported_time=models.Sum('duration')) reported_time = data['reported_time'] or timedelta() if reported_time >= employment.worktime_per_day: From 1570b8a01226597c4c321560345a296e27751c08 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 24 Oct 2017 13:46:57 +0200 Subject: [PATCH 289/980] Clarified doc of AggregateQuerysetMixin --- timed/mixins.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/timed/mixins.py b/timed/mixins.py index f0e913e00..828723c8a 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -7,13 +7,12 @@ class AggregateQuerysetMixin(object): Wrap queryst dicts into aggregate object to support renderer which expect attributes. - It additional prefetches related instances represented as id in + It additionaly prefetches related instances represented as id in aggregate. In aggregates only an id of a related field is part of the object. - Instead of loading each single object row by row this mixin - prefetches all resources related field in injects - it before serialization starts. + Instead of loading each single object row by row this mixin prefetches + all resource related fields and injects it before serialization starts. Mixin expects the id to be the same key as the resource related field defined in the serializer. From d5af2a16f689b3250e74694899ba8a892bc06f5b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 24 Oct 2017 16:25:54 +0200 Subject: [PATCH 290/980] Expose employments to make it view- and editable by other users Superuser may only edit and supervisors by view employments of its supervisees. --- timed/employment/admin.py | 11 +- timed/employment/filters.py | 7 + timed/employment/serializers.py | 53 +++- timed/employment/tests/test_employment.py | 282 ++++++++++++---------- timed/employment/views.py | 38 ++- 5 files changed, 235 insertions(+), 156 deletions(-) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 545939562..e89148bb6 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -64,14 +64,6 @@ def clean(self): 'The end date must be after the start date' )) - if ( - employments.filter(end_date__isnull=True) and - data.get('end_date') is None - ): - raise ValidationError(_( - 'A user can only have one active employment' - )) - if any([ e.start_date <= ( data.get('end_date') or @@ -80,8 +72,7 @@ def clean(self): e.end_date or datetime.date.today() ) - for e - in employments + for e in employments ]): raise ValidationError(_( 'A user can\'t have multiple employments at the same time' diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 20600d416..46ef2419e 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -45,6 +45,13 @@ class Meta: fields = ['active', 'supervisor'] +class EmploymentFilterSet(FilterSet): + + class Meta: + model = models.Employment + fields = ['user', 'location'] + + class OvertimeCreditFilterSet(FilterSet): year = YearFilter(name='date') from_date = DateFilter(name='date', lookup_expr='gte') diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index bbaf2af62..ad4bba5cf 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -3,12 +3,16 @@ from datetime import date, datetime, timedelta from django.contrib.auth import get_user_model +from django.db.models import Value +from django.db.models.functions import Coalesce from django.utils.duration import duration_string +from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import PermissionDenied from rest_framework_json_api import relations, serializers from rest_framework_json_api.serializers import (IntegerField, ModelSerializer, Serializer, - SerializerMethodField) + SerializerMethodField, + ValidationError) from timed.employment import models from timed.tracking.models import Absence @@ -58,8 +62,6 @@ def validate(self, data): 'timed.employment.serializers.UserSerializer', 'supervisees': 'timed.employment.serializers.UserSerializer', - 'employments': - 'timed.employment.serializers.EmploymentSerializer', 'user_absence_types': 'timed.employment.serializers.UserAbsenceTypeSerializer' } @@ -112,19 +114,50 @@ class Meta: class EmploymentSerializer(ModelSerializer): - """Employment serializer.""" - - user = relations.ResourceRelatedField(read_only=True) - location = relations.ResourceRelatedField(read_only=True) - included_serializers = { 'user': 'timed.employment.serializers.UserSerializer', 'location': 'timed.employment.serializers.LocationSerializer' } - class Meta: - """Meta information for the employment serializer.""" + def validate(self, data): + """Validate the employment as a whole. + + Ensure the end date is after the start date and there is only one + active employment per user and there are no overlapping employments. + :throws: django.core.exceptions.ValidationError + :return: validated data + :rtype: dict + """ + instance = self.instance + start_date = data.get('start_date', instance and instance.start_date) + end_date = data.get('end_date', instance and instance.end_date) + if end_date and start_date >= end_date: + raise ValidationError(_( + 'The end date must be after the start date' + )) + + user = data.get('user', instance and instance.user) + employments = models.Employment.objects.filter(user=user) + # end date not set means employment is ending today + end_date = end_date or date.today() + employments = employments.annotate( + end=Coalesce('end_date', Value(date.today())) + ) + if instance: + employments = employments.exclude(id=instance.id) + + if any([ + e.start_date <= end_date and start_date <= e.end + for e in employments + ]): + raise ValidationError(_( + 'A user can\'t have multiple employments at the same time' + )) + + return data + + class Meta: model = models.Employment fields = [ 'user', diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 8e2fc3d61..a4156edd1 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -4,148 +4,184 @@ import pytest from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status from timed.employment import factories from timed.employment.admin import EmploymentForm +from timed.employment.factories import (EmploymentFactory, LocationFactory, + UserFactory) from timed.employment.models import Employment -from timed.jsonapi_test_case import JSONAPITestCase from timed.tracking.factories import ReportFactory -class EmploymentTests(JSONAPITestCase): - """Tests for the employment endpoint. +def test_employment_create_authenticated(auth_client): + url = reverse('employment-list') + + result = auth_client.post(url) + assert result.status_code == status.HTTP_403_FORBIDDEN + + +def test_employment_create_superuser(superadmin_client): + url = reverse('employment-list') + location = LocationFactory.create() + + data = { + 'data': { + 'type': 'employments', + 'id': None, + 'attributes': { + 'percentage': '100', + 'worktime_per_day': '08:00:00', + 'start-date': '2017-04-01', + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': superadmin_client.user.id + } + }, + 'location': { + 'data': { + 'type': 'locations', + 'id': location.id + } + } + } + } + } + + result = superadmin_client.post(url, data) + assert result.status_code == status.HTTP_201_CREATED + + +def test_employment_update_end_before_start(superadmin_client): + employment = EmploymentFactory.create(user=superadmin_client.user) + + data = { + 'data': { + 'type': 'employments', + 'id': employment.id, + 'attributes': { + 'start_date': '2017-03-01', + 'end_date': '2017-01-01', + } + } + } + + url = reverse('employment-detail', args=[employment.id]) + result = superadmin_client.patch(url, data) + assert result.status_code == status.HTTP_400_BAD_REQUEST + + +def test_employment_update_overlapping(superadmin_client): + user = superadmin_client.user + EmploymentFactory.create(user=user, end_date=None) + employment = EmploymentFactory.create(user=user) + + data = { + 'data': { + 'type': 'employments', + 'id': employment.id, + 'attributes': { + 'end_date': None, + } + } + } + + url = reverse('employment-detail', args=[employment.id]) + result = superadmin_client.patch(url, data) + assert result.status_code == status.HTTP_400_BAD_REQUEST + + +def test_employment_list_authenticated(auth_client): + EmploymentFactory.create_batch(2) + employment = EmploymentFactory.create(user=auth_client.user) + + url = reverse('employment-list') + + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(employment.id) + + +def test_employment_list_superuser(superadmin_client): + EmploymentFactory.create_batch(2) + EmploymentFactory.create(user=superadmin_client.user) + + url = reverse('employment-list') + + result = superadmin_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 3 + + +def test_employment_list_supervisor(auth_client): + user = UserFactory.create() + auth_client.user.supervisees.add(user) + + EmploymentFactory.create_batch(1) + EmploymentFactory.create(user=auth_client.user) + EmploymentFactory.create(user=user) + + url = reverse('employment-list') + + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 2 + + +def test_employment_unique_active(db): + """Should only be able to have one active employment per user.""" + user = UserFactory.create() + EmploymentFactory.create(user=user, end_date=None) + employment = EmploymentFactory.create(user=user) + form = EmploymentForm({ + 'end_date': None + }, instance=employment) - This endpoint should be read only for normal users. - """ - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - - self.employments = [ - factories.EmploymentFactory.create( - user=self.user, - start_date=date(2010, 1, 1), - end_date=date(2015, 1, 1) - ), - factories.EmploymentFactory.create( - user=self.user, - start_date=date(2015, 1, 2) - ) - ] - - factories.EmploymentFactory.create_batch(10) - - def test_employment_list(self): - """Should respond with a list of employments.""" - url = reverse('employment-list') - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - assert len(result['data']) == len(self.employments) - - def test_employment_detail(self): - """Should respond with a single employment.""" - employment = self.employments[0] - - url = reverse('employment-detail', args=[ - employment.id - ]) - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - def test_employment_create(self): - """Should not be able to create a new employment.""" - url = reverse('employment-list') + with pytest.raises(ValueError): + form.save() - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED +def test_employment_start_before_end(db): + employment = EmploymentFactory.create() + form = EmploymentForm({ + 'start_date': date(2009, 1, 1), + 'end_date': date(2016, 1, 1) + }, instance=employment) - def test_employment_update(self): - """Should not be able to update an existing employment.""" - employment = self.employments[0] + with pytest.raises(ValueError): + form.save() - url = reverse('employment-detail', args=[ - employment.id - ]) - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) +def test_employment_get_at(db): + """Should return the right employment on a date.""" + user = UserFactory.create() + employment = EmploymentFactory.create(user=user) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_employment_delete(self): - """Should not be able delete a employment.""" - employment = self.employments[0] - - url = reverse('employment-detail', args=[ - employment.id - ]) - - noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_employment_unique_active(self): - """Should only be able to have one active employment per user.""" - form = EmploymentForm({ - 'end_date': None - }, instance=self.employments[1]) - - with pytest.raises(ValueError): - form.save() - - def test_employment_unique_range(self): - """Should only be able to have one employment at a time per user.""" - form = EmploymentForm({ - 'start_date': date(2009, 1, 1), - 'end_date': date(2016, 1, 1) - }, instance=self.employments[0]) - - with pytest.raises(ValueError): - form.save() + assert ( + Employment.objects.get_at(user, employment.start_date) == + employment + ) - def test_employment_get_at(self): - """Should return the right employment on a date.""" - employment = Employment.objects.get(user=self.user, - end_date__isnull=True) + employment.end_date = ( + employment.start_date + + timedelta(days=20) + ) - assert ( - Employment.objects.get_at(self.user, employment.start_date) == - employment - ) + employment.save() - employment.end_date = ( - employment.start_date + - timedelta(days=20) + with pytest.raises(Employment.DoesNotExist): + Employment.objects.get_at( + user, + employment.start_date + timedelta(days=21) ) - employment.save() - - with pytest.raises(Employment.DoesNotExist): - Employment.objects.get_at( - self.user, - employment.start_date + timedelta(days=21) - ) - def test_worktime_balance_partial(db): """ diff --git a/timed/employment/views.py b/timed/employment/views.py index bd658e9bc..513f786a5 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -93,24 +93,36 @@ def get_queryset(self): return queryset -class EmploymentViewSet(ReadOnlyModelViewSet): - """Employment view set.""" - +class EmploymentViewSet(ModelViewSet): serializer_class = serializers.EmploymentSerializer - ordering = ('-end_date',) + ordering = ('-end_date',) + filter_class = filters.EmploymentFilterSet + permission_classes = [ + # super user can add/read overtime credits + C(IsAuthenticated) & C(IsSuperUser) | + # user may only read filtered results + C(IsAuthenticated) & C(IsReadOnly) + ] def get_queryset(self): - """Filter the queryset by the user of the request. + """ + Get queryset of employments. - :return: The filtered employments - :rtype: QuerySet + Following rules apply: + 1. super user may see all + 2. user may see credits of all its supervisors and self + 3. user may only see its own credit """ - return models.Employment.objects.select_related( - 'user', - 'location' - ).filter( - user=self.request.user - ) + user = self.request.user + + queryset = models.Employment.objects.select_related('user', 'location') + + if not user.is_superuser: + queryset = queryset.filter( + Q(user=user) | Q(user__supervisors=user) + ) + + return queryset class LocationViewSet(ReadOnlyModelViewSet): From 89db421d759b16ae66b889fc9ad52d3ff05c6a83 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 25 Oct 2017 15:28:17 +0200 Subject: [PATCH 291/980] Move absence balances into its own view This gives the possibility to retrieve absence balances from a given date. --- timed/employment/filters.py | 8 + .../migrations/0009_delete_userabsencetype.py | 18 ++ timed/employment/models.py | 96 ------- timed/employment/serializers.py | 243 ++++++++++-------- .../employment/tests/test_absence_balance.py | 113 ++++++++ timed/employment/tests/test_user.py | 107 +------- timed/employment/urls.py | 5 + timed/employment/views.py | 102 +++++++- timed/mixins.py | 19 +- 9 files changed, 393 insertions(+), 318 deletions(-) create mode 100644 timed/employment/migrations/0009_delete_userabsencetype.py create mode 100644 timed/employment/tests/test_absence_balance.py diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 46ef2419e..9e037bad6 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -80,3 +80,11 @@ class WorktimeBalanceFilterSet(FilterSet): class Meta: model = models.User fields = ['user'] + + +class AbsenceBalanceFilterSet(FilterSet): + absence_type = NumberFilter(name='id') + + class Meta: + model = models.AbsenceType + fields = ['absence_type'] diff --git a/timed/employment/migrations/0009_delete_userabsencetype.py b/timed/employment/migrations/0009_delete_userabsencetype.py new file mode 100644 index 000000000..d7377dad4 --- /dev/null +++ b/timed/employment/migrations/0009_delete_userabsencetype.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-25 08:50 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0008_auto_20171013_1041'), + ] + + operations = [ + migrations.DeleteModel( + name='UserAbsenceType', + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index e8d33f3eb..cf6100163 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -115,102 +115,6 @@ class OvertimeCredit(models.Model): duration = models.DurationField(default=timedelta(0)) -class UserAbsenceTypeManager(models.Manager): - def with_user(self, user, start_date, end_date): - """Get all user absence types with the needed calculations. - - This is achieved using a raw query because the calculations were too - complicated to do with django annotations / aggregations. Since those - proxy models are read only and don't need to be filtered or anything, - the raw query shouldn't block any needed functions. - - :param User user: The user of the user absence type - :param datetime.date start_date: Start date of the user absence type - :param datetime.date end_date: End date of the user absence type - :returns: User absence types for the requested user - :rtype: django.db.models.QuerySet - """ - from timed.tracking.models import Absence - - return UserAbsenceType.objects.raw(""" - SELECT - at.*, - %(user_id)s AS user_id, - %(start)s AS start_date, - %(end)s AS end_date, - CASE - WHEN at.fill_worktime THEN NULL - ELSE credit_join.credit - END AS credit, - CASE - WHEN at.fill_worktime THEN NULL - ELSE used_join.used_days - END AS used_days, - CASE - WHEN at.fill_worktime THEN NULL - ELSE credit_join.credit - used_join.used_days - END AS balance - FROM {absencetype_table} AS at - LEFT JOIN ( - SELECT - at.id, - SUM(ac.days) AS credit - FROM {absencetype_table} AS at - LEFT JOIN {absencecredit_table} AS ac ON ( - ac.absence_type_id = at.id - AND - ac.user_id = %(user_id)s - AND - ac.date BETWEEN %(start)s AND %(end)s - ) - GROUP BY at.id, ac.absence_type_id - ) AS credit_join ON (at.id = credit_join.id) - LEFT JOIN ( - SELECT - at.id, - COUNT(a.id) AS used_days - FROM {absencetype_table} AS at - LEFT JOIN {absence_table} AS a ON ( - a.type_id = at.id - and - a.user_id = %(user_id)s - AND - a.date BETWEEN %(start)s AND %(end)s - ) - GROUP BY at.id, a.type_id - ) AS used_join ON (at.id = used_join.id) - """.format( - absence_table=Absence._meta.db_table, - absencetype_table=AbsenceType._meta.db_table, - absencecredit_table=AbsenceCredit._meta.db_table - ), { - 'user_id': user.id, - 'start': start_date, - 'end': end_date - }) - - -class UserAbsenceType(AbsenceType): - """User absence type. - - This is a proxy for the absence type model used to generate a fake relation - between a user and an absence type. This is required so we can expose the - absence credits in a clean way to the API. - - The PK of this model is a combination of the user ID and the actual absence - type ID. - """ - - objects = UserAbsenceTypeManager() - - @property - def pk(self): - return '{0}-{1}'.format(self.user_id, self.id) - - class Meta: - proxy = True - - class EmploymentManager(models.Manager): """Custom manager for employments.""" diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index ad4bba5cf..0cdd6f678 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -1,16 +1,15 @@ """Serializers for the employment app.""" -from datetime import date, datetime, timedelta +from datetime import date, timedelta from django.contrib.auth import get_user_model -from django.db.models import Value +from django.db.models import Sum, Value from django.db.models.functions import Coalesce from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import PermissionDenied from rest_framework_json_api import relations, serializers -from rest_framework_json_api.serializers import (IntegerField, ModelSerializer, - Serializer, +from rest_framework_json_api.serializers import (ModelSerializer, Serializer, SerializerMethodField, ValidationError) @@ -21,32 +20,7 @@ class UserSerializer(ModelSerializer): """User serializer.""" - employments = relations.ResourceRelatedField(many=True, - read_only=True) - user_absence_types = relations.SerializerMethodResourceRelatedField( - source='get_user_absence_types', - model=models.UserAbsenceType, - many=True, - read_only=True - ) - - def get_user_absence_types(self, instance): - """Get the user absence types for this user. - - :returns: All absence types for this user - """ - request = self.context.get('request') - until = request and request.query_params.get('until') - - end = datetime.strptime( - until or date.today().strftime('%Y-%m-%d'), - '%Y-%m-%d' - ).date() - start = date(end.year, 1, 1) - - return models.UserAbsenceType.objects.with_user(instance, - start, - end) + employments = relations.ResourceRelatedField(many=True, read_only=True) def validate(self, data): user = self.context['request'].user @@ -62,8 +36,6 @@ def validate(self, data): 'timed.employment.serializers.UserSerializer', 'supervisees': 'timed.employment.serializers.UserSerializer', - 'user_absence_types': - 'timed.employment.serializers.UserAbsenceTypeSerializer' } class Meta: @@ -79,7 +51,6 @@ class Meta: 'is_staff', 'is_superuser', 'is_active', - 'user_absence_types', 'tour_done', 'supervisors', 'supervisees' @@ -113,6 +84,134 @@ class Meta: resource_name = 'worktime-balances' +class AbsenceBalanceSerializer(Serializer): + credit = SerializerMethodField() + used_days = SerializerMethodField() + used_duration = SerializerMethodField() + balance = SerializerMethodField() + + user = relations.ResourceRelatedField( + model=get_user_model(), read_only=True + ) + + absence_type = relations.ResourceRelatedField( + model=models.AbsenceType, read_only=True, source='id' + ) + + absence_credits = relations.SerializerMethodResourceRelatedField( + source='get_absence_credits', + model=models.AbsenceCredit, + many=True, + read_only=True + ) + + def _get_start(self, instance): + return date(instance.date.year, 1, 1) + + def get_credit(self, instance): + """ + Calculate how many days are approved for given absence type. + + For absence types which fill worktime this will be None. + """ + # id is mapped to absence type + absence_type = instance.id + if absence_type.fill_worktime: + return None + + start = self._get_start(instance) + credits = models.AbsenceCredit.objects.filter( + user=instance.user, + absence_type=absence_type, + date__range=[start, instance.date] + ) + data = credits.aggregate(credit=Sum('days')) + credit = data['credit'] or 0 + + # balance will need this value + instance['credit'] = credit + return credit + + def get_used_days(self, instance): + """ + Calculate how many days are used of given absence type. + + For absence types which fill worktime this will be None. + """ + # id is mapped to absence type + absence_type = instance.id + if absence_type.fill_worktime: + return None + + start = self._get_start(instance) + absences = Absence.objects.filter( + user=instance.user, + type=absence_type, + date__range=[start, instance.date] + ) + + # balance will need this value + used_days = absences.count() + instance['used_days'] = used_days + return used_days + + def get_used_duration(self, instance): + """ + Calculate duration of absence type. + + For absence types which fill worktime this will be None. + """ + # id is mapped to absence type + absence_type = instance.id + if not absence_type.fill_worktime: + return None + + start = self._get_start(instance) + absences = sum([ + absence.calculate_duration( + models.Employment.objects.get_at( + instance.user, absence.date + ) + ) + for absence in Absence.objects.filter( + user=instance.user, + date__range=[start, instance.date], + type_id=instance.id + ).select_related('type') + ], timedelta()) + return duration_string(absences) + + def get_absence_credits(self, instance): + """Get the absence credits for the user and type.""" + # id is mapped to absence type + absence_type = instance.id + + start = self._get_start(instance) + return models.AbsenceCredit.objects.filter( + absence_type=absence_type, + user=instance.user, + date__range=[start, instance.date], + ) + + def get_balance(self, instance): + # id is mapped to absence type + absence_type = instance.id + if absence_type.fill_worktime: + return None + + return instance['credit'] - instance['used_days'] + + included_serializers = { + 'absence_type': + 'timed.employment.serializers.AbsenceTypeSerializer', + 'absence_credits': + 'timed.employment.serializers.AbsenceCreditSerializer', + } + + class Meta: + resource_name = 'absence-balances' + + class EmploymentSerializer(ModelSerializer): included_serializers = { 'user': 'timed.employment.serializers.UserSerializer', @@ -199,84 +298,6 @@ class Meta: ] -class UserAbsenceTypeSerializer(ModelSerializer): - """Absence type serializer for a user. - - This is only a simulated relation to the user to show the absence credits - and balances. - """ - - credit = IntegerField() - used_days = IntegerField() - used_duration = SerializerMethodField(source='get_used_duration') - balance = IntegerField() - - user = relations.SerializerMethodResourceRelatedField( - source='get_user', - model=get_user_model(), - read_only=True - ) - - absence_credits = relations.SerializerMethodResourceRelatedField( - source='get_absence_credits', - model=models.AbsenceCredit, - many=True, - read_only=True - ) - - def get_user(self, instance): - return get_user_model().objects.get(pk=instance.user_id) - - def get_used_duration(self, instance): - # only calculate worktime if it fills up day - if not instance.fill_worktime: - return None - - absences = sum([ - absence.calculate_duration( - models.Employment.objects.get_at( - instance.user_id, absence.date - ) - ) - for absence in Absence.objects.filter( - user=instance.user_id, - date__gte=instance.start_date, - date__lte=instance.end_date, - type_id=instance.id - ) - ], timedelta()) - return duration_string(absences) - - def get_absence_credits(self, instance): - """Get the absence credits for the user and type.""" - return models.AbsenceCredit.objects.filter( - absence_type=instance, - user__id=instance.user_id, - date__gte=instance.start_date, - date__lte=instance.end_date - ) - - included_serializers = { - 'absence_credits': - 'timed.employment.serializers.AbsenceCreditSerializer', - } - - class Meta: - """Meta information for the absence type serializer.""" - - model = models.UserAbsenceType - fields = [ - 'name', - 'fill_worktime', - 'credit', - 'used_duration', - 'used_days', - 'balance', - 'absence_credits', - 'user', - ] - - class AbsenceTypeSerializer(ModelSerializer): """Absence type serializer.""" diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py new file mode 100644 index 000000000..645f3b6fd --- /dev/null +++ b/timed/employment/tests/test_absence_balance.py @@ -0,0 +1,113 @@ +from datetime import date, timedelta + +from django.core.urlresolvers import reverse +from rest_framework import status + +from timed.employment.factories import (AbsenceCreditFactory, + AbsenceTypeFactory, EmploymentFactory) +from timed.tracking.factories import AbsenceFactory, ReportFactory + + +def test_absence_balance_full_day(auth_client, django_assert_num_queries): + day = date(2017, 2, 28) + + user = auth_client.user + EmploymentFactory.create(user=user, start_date=day) + absence_type = AbsenceTypeFactory.create() + + AbsenceCreditFactory.create( + date=day, + user=user, + days=5, + absence_type=absence_type + ) + + # credit on different user, may not show up + AbsenceCreditFactory.create( + date=date.today(), + absence_type=absence_type + ) + + AbsenceFactory.create( + date=day, + user=user, + type=absence_type + ) + + AbsenceFactory.create( + date=day - timedelta(days=1), + user=user, + type=absence_type + ) + + url = reverse('absence-balance-list') + + with django_assert_num_queries(7): + result = auth_client.get(url, data={ + 'date': '2017-03-01', + 'user': user.id, + 'include': 'absence_credits,absence_type' + }) + + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 1 + entry = json['data'][0] + + assert ( + entry['id'] == + '{0}_{1}_2017-03-01'.format(user.id, absence_type.id) + ) + assert entry['attributes']['credit'] == 5 + assert entry['attributes']['used-days'] == 2 + assert entry['attributes']['used-duration'] is None + assert entry['attributes']['balance'] == 3 + + assert len(json['included']) == 2 + + +def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): + day = date(2017, 2, 28) + + user = auth_client.user + EmploymentFactory.create( + user=user, start_date=day, + worktime_per_day=timedelta(hours=5) + ) + absence_type = AbsenceTypeFactory.create(fill_worktime=True) + + ReportFactory.create( + user=user, + date=day + timedelta(days=1), + duration=timedelta(hours=4) + ) + + AbsenceFactory.create(date=day + timedelta(days=1), + user=user, + type=absence_type) + + AbsenceFactory.create(date=day, + user=user, + type=absence_type) + + url = reverse('absence-balance-list') + with django_assert_num_queries(10): + result = auth_client.get(url, data={ + 'date': '2017-03-01', + 'user': user.id + }) + assert result.status_code == status.HTTP_200_OK + + json = result.json() + assert len(json['data']) == 1 + entry = json['data'][0] + + assert ( + entry['id'] == + '{0}_{1}_2017-03-01'.format(user.id, absence_type.id) + ) + + assert entry['attributes']['credit'] is None + assert entry['attributes']['balance'] is None + assert entry['attributes']['used-days'] is None + assert entry['attributes']['used-duration'] == '06:00:00' diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 464d7f8ad..5319230b0 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -1,17 +1,12 @@ """Tests for the locations endpoint.""" -from datetime import date, timedelta - from django.core.urlresolvers import reverse from rest_framework import status from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_405_METHOD_NOT_ALLOWED) -from timed.employment.factories import (AbsenceCreditFactory, - AbsenceTypeFactory, EmploymentFactory, - UserFactory) +from timed.employment.factories import EmploymentFactory, UserFactory from timed.jsonapi_test_case import JSONAPITestCase -from timed.tracking.factories import AbsenceFactory, ReportFactory class UserTests(JSONAPITestCase): @@ -135,106 +130,6 @@ def test_user_delete(self): assert noauth_res.status_code == HTTP_401_UNAUTHORIZED assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - def test_user_absence_types(self): - absence_type = AbsenceTypeFactory.create() - - absence_credit = AbsenceCreditFactory.create(date=date.today(), - user=self.user, days=5, - absence_type=absence_type) - - # credit on different user, may not show up on included - AbsenceCreditFactory.create(date=date.today(), - absence_type=absence_type) - - AbsenceFactory.create(date=date.today(), - user=self.user, - type=absence_type) - - AbsenceFactory.create(date=date.today() - timedelta(days=1), - user=self.user, - type=absence_type) - - url = reverse('user-detail', args=[ - self.user.id - ]) - - res = self.client.get(url, { - 'include': 'user_absence_types,user_absence_types.absence_credits' - }) - - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - rel = result['data']['relationships'] - inc = result['included'] - - assert len(rel['user-absence-types']['data']) == 1 - assert len(inc) == 2 - - absence_type_inc = inc[0] - assert absence_type_inc['id'] == str(absence_credit.id) - - absence_credit_inc = inc[1] - assert ( - absence_credit_inc['id'] == - '{0}-{1}'.format(self.user.id, absence_credit.absence_type.id) - ) - assert absence_credit_inc['attributes']['credit'] == 5 - assert absence_credit_inc['attributes']['balance'] == 3 - assert absence_credit_inc['attributes']['used-days'] == 2 - assert absence_credit_inc['attributes']['used-duration'] is None - - def test_user_absence_types_fill_worktime(self): - absence_type = AbsenceTypeFactory.create(fill_worktime=True) - - employment = self.user.employments.get(end_date__isnull=True) - - employment.worktime_per_day = timedelta(hours=5) - employment.start_date = date.today() - timedelta(days=1) - - employment.save() - - ReportFactory.create( - user=self.user, - date=date.today(), - duration=timedelta(hours=4) - ) - - AbsenceFactory.create(date=date.today(), - user=self.user, - type=absence_type) - - AbsenceFactory.create(date=date.today() - timedelta(days=1), - user=self.user, - type=absence_type) - - url = reverse('user-detail', args=[ - self.user.id - ]) - - res = self.client.get(url, {'include': 'user_absence_types'}) - - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - rel = result['data']['relationships'] - inc = result['included'] - - assert len(rel['user-absence-types']['data']) == 1 - assert len(inc) == 1 - - assert ( - inc[0]['id'] == - '{0}-{1}'.format(self.user.id, absence_type.id) - ) - - assert inc[0]['attributes']['credit'] is None - assert inc[0]['attributes']['balance'] is None - assert inc[0]['attributes']['used-days'] is None - assert inc[0]['attributes']['used-duration'] == '06:00:00' - def test_user_supervisor_filter(auth_client): """Should filter users by supervisor.""" diff --git a/timed/employment/urls.py b/timed/employment/urls.py index f653f963c..c2b3562e4 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -19,5 +19,10 @@ views.WorktimeBalanceViewSet, 'worktime-balance' ) +r.register( + r'absence-balances', + views.AbsenceBalanceViewSet, + 'absence-balance' +) urlpatterns = r.urls diff --git a/timed/employment/views.py b/timed/employment/views.py index 513f786a5..d48d19216 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -2,7 +2,7 @@ import datetime from django.contrib.auth import get_user_model -from django.db.models import CharField, DateField, Q, Value +from django.db.models import CharField, DateField, IntegerField, Q, Value from django.db.models.functions import Concat from django.utils.translation import ugettext_lazy as _ from rest_condition import C @@ -93,6 +93,106 @@ def get_queryset(self): return queryset +class AbsenceBalanceViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): + """Calculate absence balance for different user on different dates.""" + + serializer_class = serializers.AbsenceBalanceSerializer + filter_class = filters.AbsenceBalanceFilterSet + + def _extract_date(self): + """ + Extract date from request. + + In detail route extract it from pk and it list + from query params. + """ + pk = self.request.parser_context['kwargs'].get('pk') + + # detail case + if pk is not None: + try: + return datetime.datetime.strptime( + pk.split('_')[2], '%Y-%m-%d' + ) + + except (ValueError, TypeError, IndexError): + raise exceptions.NotFound() + + # list case + try: + return datetime.datetime.strptime( + self.request.query_params.get('date'), + '%Y-%m-%d' + ).date() + except ValueError: + raise exceptions.ParseError(_('Date is invalid')) + except TypeError: + raise exceptions.ParseError(_('Date filter needs to be set')) + + def _extract_user(self): + """ + Extract user from request. + + In detail route extract it from pk and it list + from query params. + """ + pk = self.request.parser_context['kwargs'].get('pk') + + # detail case + if pk is not None: + try: + user_id = int(pk.split('_')[0]) + # avoid query if user is self + if self.request.user.pk == user_id: + return self.request.user + return get_user_model().objects.get(pk=pk.split('_')[0]) + except (ValueError, get_user_model().DoesNotExist): + raise exceptions.NotFound() + + # list case + try: + user_id = self.request.query_params.get('user') + if user_id is None: + raise exceptions.ParseError(_('User filter needs to be set')) + + # avoid query if user is self + if self.request.user.pk == int(user_id): + return self.request.user + + return get_user_model().objects.get(pk=user_id) + except (ValueError, get_user_model().DoesNotExist): + raise exceptions.ParseError(_('User is invalid')) + + def get_queryset(self): + date = self._extract_date() + user = self._extract_user() + + queryset = models.AbsenceType.objects.values('id') + queryset = queryset.annotate( + date=Value(date, DateField()), + ) + queryset = queryset.annotate( + user=Value(user.id, IntegerField()), + ) + queryset = queryset.annotate( + pk=Concat( + 'user', + Value('_'), + 'id', + Value('_'), + 'date', + output_field=CharField() + ) + ) + + # if not user.is_superuser: + # queryset = queryset.filter( + # Q(id=user.id) | Q(supervisors=user) + # ) + + return queryset + + class EmploymentViewSet(ModelViewSet): serializer_class = serializers.EmploymentSerializer ordering = ('-end_date',) diff --git a/timed/mixins.py b/timed/mixins.py index 828723c8a..db4c14090 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -1,4 +1,4 @@ -from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api import relations class AggregateQuerysetMixin(object): @@ -7,7 +7,7 @@ class AggregateQuerysetMixin(object): Wrap queryst dicts into aggregate object to support renderer which expect attributes. - It additionaly prefetches related instances represented as id in + It additionally prefetches related instances represented as id in aggregate. In aggregates only an id of a related field is part of the object. @@ -22,7 +22,7 @@ class AggregateObject(dict): """ Wrap dict into an object. - All values will be accesible through attributes. Note that + All values will be accessible through attributes. Note that keys must be valid python names for this to work. """ @@ -30,6 +30,17 @@ def __init__(self, **kwargs): self.__dict__.update(kwargs) super().__init__(**kwargs) + def _is_related_field(self, val): + """ + Check whether value is a related field. + + Ignores serializer method fields which define logic separately. + """ + return ( + isinstance(val, relations.ResourceRelatedField) and + not isinstance(val, relations.SerializerMethodResourceRelatedField) + ) + def get_serializer(self, data, *args, **kwargs): many = kwargs.get('many') if not many: @@ -39,7 +50,7 @@ def get_serializer(self, data, *args, **kwargs): prefetch_per_field = {} serializer_class = self.get_serializer_class() for key, value in serializer_class._declared_fields.items(): - if isinstance(value, ResourceRelatedField): + if self._is_related_field(value): source = value.source or key if many: obj_ids = data.values_list(source, flat=True) From 7ddc9a82a94743b19ae318b09366dcc0d497f8cc Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 25 Oct 2017 15:41:57 +0200 Subject: [PATCH 292/980] Improve performance when serializer methods get called multiple times --- timed/employment/serializers.py | 26 ++++++++++++++----- .../employment/tests/test_absence_balance.py | 3 ++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 0cdd6f678..9eaf6e206 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -114,6 +114,9 @@ def get_credit(self, instance): For absence types which fill worktime this will be None. """ + if 'credit' in instance: + return instance['credit'] + # id is mapped to absence type absence_type = instance.id if absence_type.fill_worktime: @@ -128,7 +131,7 @@ def get_credit(self, instance): data = credits.aggregate(credit=Sum('days')) credit = data['credit'] or 0 - # balance will need this value + # avoid multiple calculations as get_balance needs it as well instance['credit'] = credit return credit @@ -138,6 +141,9 @@ def get_used_days(self, instance): For absence types which fill worktime this will be None. """ + if 'used_days' in instance: + return instance['used_days'] + # id is mapped to absence type absence_type = instance.id if absence_type.fill_worktime: @@ -149,9 +155,9 @@ def get_used_days(self, instance): type=absence_type, date__range=[start, instance.date] ) - - # balance will need this value used_days = absences.count() + + # avoid multiple calculations as get_balance needs it as well instance['used_days'] = used_days return used_days @@ -183,15 +189,23 @@ def get_used_duration(self, instance): def get_absence_credits(self, instance): """Get the absence credits for the user and type.""" + if 'absence_credits' in instance: + return instance['absence_credits'] + # id is mapped to absence type absence_type = instance.id start = self._get_start(instance) - return models.AbsenceCredit.objects.filter( + absence_credits = models.AbsenceCredit.objects.filter( absence_type=absence_type, user=instance.user, date__range=[start, instance.date], - ) + ).select_related('user') + + # avoid multiple calculations when absence credits need to be included + instance['absence_credits'] = absence_credits + + return absence_credits def get_balance(self, instance): # id is mapped to absence type @@ -199,7 +213,7 @@ def get_balance(self, instance): if absence_type.fill_worktime: return None - return instance['credit'] - instance['used_days'] + return self.get_credit(instance) - self.get_used_days(instance) included_serializers = { 'absence_type': diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py index 645f3b6fd..2bfc9a2cb 100644 --- a/timed/employment/tests/test_absence_balance.py +++ b/timed/employment/tests/test_absence_balance.py @@ -94,7 +94,8 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): with django_assert_num_queries(10): result = auth_client.get(url, data={ 'date': '2017-03-01', - 'user': user.id + 'user': user.id, + 'include': 'absence_credits,absence_type' }) assert result.status_code == status.HTTP_200_OK From ef29074aee3c9ce5e16a92e3193de1e6bc0610f2 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 25 Oct 2017 16:09:57 +0200 Subject: [PATCH 293/980] Test invalid filters on absence balance view --- .../employment/tests/test_absence_balance.py | 78 ++++++++++++++++++- timed/employment/views.py | 4 +- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py index 2bfc9a2cb..cc818dd7e 100644 --- a/timed/employment/tests/test_absence_balance.py +++ b/timed/employment/tests/test_absence_balance.py @@ -4,7 +4,8 @@ from rest_framework import status from timed.employment.factories import (AbsenceCreditFactory, - AbsenceTypeFactory, EmploymentFactory) + AbsenceTypeFactory, EmploymentFactory, + UserFactory) from timed.tracking.factories import AbsenceFactory, ReportFactory @@ -69,7 +70,7 @@ def test_absence_balance_full_day(auth_client, django_assert_num_queries): def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): day = date(2017, 2, 28) - user = auth_client.user + user = UserFactory.create() EmploymentFactory.create( user=user, start_date=day, worktime_per_day=timedelta(hours=5) @@ -91,7 +92,7 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): type=absence_type) url = reverse('absence-balance-list') - with django_assert_num_queries(10): + with django_assert_num_queries(11): result = auth_client.get(url, data={ 'date': '2017-03-01', 'user': user.id, @@ -112,3 +113,74 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): assert entry['attributes']['balance'] is None assert entry['attributes']['used-days'] is None assert entry['attributes']['used-duration'] == '06:00:00' + + +def test_absence_balance_detail(auth_client): + user = auth_client.user + absence_type = AbsenceTypeFactory.create() + url = reverse('absence-balance-detail', args=[ + '{0}_{1}_2017-03-01'.format(user.id, absence_type.id) + ]) + + result = auth_client.get(url) + assert result.status_code == status.HTTP_200_OK + + json = result.json() + entry = json['data'] + + assert entry['attributes']['credit'] == 0 + assert entry['attributes']['balance'] == 0 + assert entry['attributes']['used-days'] == 0 + assert entry['attributes']['used-duration'] is None + + +def test_absence_balance_invalid_date_in_pk(auth_client): + url = reverse('absence-balance-detail', args=['1_2_invalid']) + + result = auth_client.get(url) + assert result.status_code == status.HTTP_404_NOT_FOUND + + +def test_absence_balance_invalid_user_in_pk(auth_client): + url = reverse('absence-balance-detail', args=['999999_2_2017-03-01']) + + result = auth_client.get(url) + assert result.status_code == status.HTTP_404_NOT_FOUND + + +def test_absence_balance_no_date(auth_client): + url = reverse('absence-balance-list') + + result = auth_client.get(url, data={ + 'user': auth_client.user.id + }) + assert result.status_code == status.HTTP_400_BAD_REQUEST + + +def test_absence_balance_invalid_date(auth_client): + url = reverse('absence-balance-list') + + result = auth_client.get(url, data={ + 'user': auth_client.user.id, + 'date': 'invalid' + }) + assert result.status_code == status.HTTP_400_BAD_REQUEST + + +def test_absence_balance_no_user(auth_client): + url = reverse('absence-balance-list') + + result = auth_client.get(url, data={ + 'date': '2017-03-01' + }) + assert result.status_code == status.HTTP_400_BAD_REQUEST + + +def test_absence_balance_invalid_user(auth_client): + url = reverse('absence-balance-list') + + result = auth_client.get(url, data={ + 'date': '2017-03-01', + 'user': 'invalid' + }) + assert result.status_code == status.HTTP_400_BAD_REQUEST diff --git a/timed/employment/views.py b/timed/employment/views.py index d48d19216..543d31bbc 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -143,7 +143,7 @@ def _extract_user(self): try: user_id = int(pk.split('_')[0]) # avoid query if user is self - if self.request.user.pk == user_id: + if self.request.user.id == user_id: return self.request.user return get_user_model().objects.get(pk=pk.split('_')[0]) except (ValueError, get_user_model().DoesNotExist): @@ -156,7 +156,7 @@ def _extract_user(self): raise exceptions.ParseError(_('User filter needs to be set')) # avoid query if user is self - if self.request.user.pk == int(user_id): + if self.request.user.id == int(user_id): return self.request.user return get_user_model().objects.get(pk=user_id) From 6aefe94d0ed1899866a84b8662a9f83e95bc51d0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 25 Oct 2017 16:58:24 +0200 Subject: [PATCH 294/980] Prevent none superuser or supervisors to see absence balances --- .../employment/tests/test_absence_balance.py | 29 ++++++++++++++++++- timed/employment/views.py | 10 ++++--- timed/mixins.py | 4 +++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py index cc818dd7e..f724e523c 100644 --- a/timed/employment/tests/test_absence_balance.py +++ b/timed/employment/tests/test_absence_balance.py @@ -71,6 +71,7 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): day = date(2017, 2, 28) user = UserFactory.create() + user.supervisors.add(auth_client.user) EmploymentFactory.create( user=user, start_date=day, worktime_per_day=timedelta(hours=5) @@ -92,7 +93,7 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): type=absence_type) url = reverse('absence-balance-list') - with django_assert_num_queries(11): + with django_assert_num_queries(12): result = auth_client.get(url, data={ 'date': '2017-03-01', 'user': user.id, @@ -134,6 +135,32 @@ def test_absence_balance_detail(auth_client): assert entry['attributes']['used-duration'] is None +def test_absence_balance_list_none_supervisee(auth_client): + url = reverse('absence-balance-list') + AbsenceTypeFactory.create() + unrelated_user = UserFactory.create() + + result = auth_client.get(url, data={ + 'user': unrelated_user.id, + 'date': '2017-01-03' + }) + assert result.status_code == status.HTTP_200_OK + assert len(result.json()['data']) == 0 + + +def test_absence_balance_detail_none_supervisee(auth_client): + url = reverse('absence-balance-list') + absence_type = AbsenceTypeFactory.create() + unrelated_user = UserFactory.create() + + url = reverse('absence-balance-detail', args=[ + '{0}_{1}_2017-03-01'.format(unrelated_user.id, absence_type.id) + ]) + + result = auth_client.get(url) + assert result.status_code == status.HTTP_404_NOT_FOUND + + def test_absence_balance_invalid_date_in_pk(auth_client): url = reverse('absence-balance-detail', args=['1_2_invalid']) diff --git a/timed/employment/views.py b/timed/employment/views.py index 543d31bbc..021edcbf6 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -185,10 +185,12 @@ def get_queryset(self): ) ) - # if not user.is_superuser: - # queryset = queryset.filter( - # Q(id=user.id) | Q(supervisors=user) - # ) + # only myself, superuser and supervisors may see by absence balances + if not user.is_superuser: + current_user = self.request.user + if current_user.id != user.id: + if not current_user.supervisees.filter(id=user.id).exists(): + return models.AbsenceType.objects.none() return queryset diff --git a/timed/mixins.py b/timed/mixins.py index db4c14090..cd61ee4f7 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -42,6 +42,10 @@ def _is_related_field(self, val): ) def get_serializer(self, data, *args, **kwargs): + # no data no wrapping needed + if not data: + return super().get_serializer(data, *args, **kwargs) + many = kwargs.get('many') if not many: data = [data] From e715eae162b36e9d02fc3a94f24aafa193f3169b Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 26 Oct 2017 16:39:37 +0200 Subject: [PATCH 295/980] Fix superuser check for absence balances --- timed/employment/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/timed/employment/views.py b/timed/employment/views.py index 021edcbf6..d82e28ba9 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -186,8 +186,9 @@ def get_queryset(self): ) # only myself, superuser and supervisors may see by absence balances - if not user.is_superuser: - current_user = self.request.user + current_user = self.request.user + + if not current_user.is_superuser: if current_user.id != user.id: if not current_user.supervisees.filter(id=user.id).exists(): return models.AbsenceType.objects.none() From a20fbb94e73cac37204819ca845b8b6e999a8320 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 30 Oct 2017 10:34:56 +0100 Subject: [PATCH 296/980] Add date filter to employment end point --- timed/employment/filters.py | 18 ++++++++++++++++-- timed/employment/tests/test_employment.py | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 9e037bad6..e52b5fd11 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,6 +1,7 @@ -"""Filters for filtering the data of the employment app endpoints.""" - +from datetime import date +from django.db.models import Value +from django.db.models.functions import Coalesce from django_filters import DateFilter, Filter, FilterSet, NumberFilter from timed.employment import models @@ -46,6 +47,19 @@ class Meta: class EmploymentFilterSet(FilterSet): + date = DateFilter(method='filter_date') + + def filter_date(self, queryset, name, value): + queryset = queryset.annotate( + end=Coalesce('end_date', Value(date.today())) + ) + + queryset = queryset.filter( + start_date__lte=value, + end__gte=value + ) + + return queryset class Meta: model = models.Employment diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index a4156edd1..c9c34e622 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -119,6 +119,29 @@ def test_employment_list_superuser(superadmin_client): assert len(json['data']) == 3 +def test_employment_list_filter_date(auth_client): + EmploymentFactory.create( + user=auth_client.user, + start_date=date(2017, 1, 1,), + end_date=date(2017, 4, 1,) + ) + employment = EmploymentFactory.create( + user=auth_client.user, + start_date=date(2017, 4, 2,), + end_date=None + ) + + url = reverse('employment-list') + + result = auth_client.get(url, data={ + 'date': '2017-04-05' + }) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(employment.id) + + def test_employment_list_supervisor(auth_client): user = UserFactory.create() auth_client.user.supervisees.add(user) From fe834c83ea1b7b3d9ea2f2bd17e019fb2b4ca64f Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 30 Oct 2017 14:33:43 +0100 Subject: [PATCH 297/980] Add absence type as included for the absence endpoint --- timed/tracking/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index ee10faed4..a7ac0e7f7 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -198,6 +198,7 @@ class AbsenceSerializer(ModelSerializer): included_serializers = { 'user': 'timed.employment.serializers.UserSerializer', + 'type': 'timed.employment.serializers.AbsenceTypeSerializer', } def get_duration(self, instance): From e8e1edc72e8658da90f1a7e32ca3a2294e6a494e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 30 Oct 2017 15:16:08 +0100 Subject: [PATCH 298/980] Add last reported date filter to worktime balance Only returns worktime balances of every user at the date when either a report or a absence has been reported. --- timed/employment/filters.py | 3 ++ timed/employment/serializers.py | 34 ++++++++++--- .../employment/tests/test_worktime_balance.py | 48 +++++++++++++++++++ timed/employment/views.py | 18 +++++-- 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index e52b5fd11..2aeb03db7 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -90,6 +90,9 @@ class Meta: class WorktimeBalanceFilterSet(FilterSet): user = NumberFilter(name='id') + # additional filters analyzed in WorktimeBalanceView + # date = DateFilter() + # last_reported_date = NumberFilter() class Meta: model = models.User diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 9eaf6e206..c537430b7 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -3,18 +3,18 @@ from datetime import date, timedelta from django.contrib.auth import get_user_model -from django.db.models import Sum, Value +from django.db.models import Max, Sum, Value from django.db.models.functions import Coalesce from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import PermissionDenied -from rest_framework_json_api import relations, serializers +from rest_framework_json_api import relations from rest_framework_json_api.serializers import (ModelSerializer, Serializer, SerializerMethodField, ValidationError) from timed.employment import models -from timed.tracking.models import Absence +from timed.tracking.models import Absence, Report class UserSerializer(ModelSerializer): @@ -67,17 +67,39 @@ class Meta: class WorktimeBalanceSerializer(Serializer): - date = serializers.DateField() + date = SerializerMethodField() balance = SerializerMethodField() user = relations.ResourceRelatedField( model=get_user_model(), read_only=True, source='id' ) + def get_date(self, instance): + user = instance.id + today = date.today() + + if instance.date is not None: + return instance.date + + # calculate last reported day if no specific date is set + max_absence_date = Absence.objects.filter(user=user).exclude( + date=today).aggregate(date=Max('date')) + max_report_date = Report.objects.filter(user=user).exclude( + date=today).aggregate(date=Max('date')) + + last_reported_date = max( + max_absence_date['date'] or date.min, + max_report_date['date'] or date.min + ) + + instance.date = last_reported_date + return instance.date + def get_balance(self, instance): - start = date(instance.date.year, 1, 1) + balance_date = self.get_date(instance) + start = date(balance_date.year, 1, 1) # id is mapped to user instance - _, _, balance = instance.id.calculate_worktime(start, instance.date) + _, _, balance = instance.id.calculate_worktime(start, balance_date) return duration_string(balance) class Meta: diff --git a/timed/employment/tests/test_worktime_balance.py b/timed/employment/tests/test_worktime_balance.py index e8a08b51a..487ac704d 100644 --- a/timed/employment/tests/test_worktime_balance.py +++ b/timed/employment/tests/test_worktime_balance.py @@ -186,3 +186,51 @@ def test_worktime_balance_list_filter_user(auth_client): json = result.json() assert len(json['data']) == 1 + + +def test_worktime_balance_list_last_reported_date_no_reports( + auth_client, django_assert_num_queries): + + url = reverse('worktime-balance-list') + + with django_assert_num_queries(2): + result = auth_client.get(url, data={ + 'last_reported_date': 1 + }) + + assert result.status_code == status.HTTP_200_OK + + json = result.json() + assert len(json['data']) == 0 + + +def test_worktime_balance_list_last_reported_date( + auth_client, django_assert_num_queries): + + EmploymentFactory.create( + user=auth_client.user, + start_date=date(2017, 2, 1), + end_date=date(2017, 2, 2), + worktime_per_day=timedelta(hours=8), + ) + + ReportFactory.create( + user=auth_client.user, + date=date(2017, 2, 1), + duration=timedelta(hours=10) + ) + + url = reverse('worktime-balance-list') + + with django_assert_num_queries(10): + result = auth_client.get(url, data={ + 'last_reported_date': 1 + }) + + assert result.status_code == status.HTTP_200_OK + + json = result.json() + assert len(json['data']) == 1 + entry = json['data'][0] + assert entry['attributes']['date'] == '2017-02-01' + assert entry['attributes']['balance'] == '02:00:00' diff --git a/timed/employment/views.py b/timed/employment/views.py index d82e28ba9..b0fb57a85 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -12,6 +12,7 @@ from timed.employment import filters, models, serializers from timed.mixins import AggregateQuerysetMixin from timed.permissions import IsAuthenticated, IsReadOnly, IsSuperUser +from timed.tracking.models import Absence, Report class UserViewSet(mixins.RetrieveModelMixin, @@ -59,15 +60,18 @@ def _extract_date(self): raise exceptions.NotFound() # list case + query_params = self.request.query_params try: return datetime.datetime.strptime( - self.request.query_params.get('date'), - '%Y-%m-%d' + query_params.get('date'), '%Y-%m-%d' ).date() except ValueError: raise exceptions.ParseError(_('Date is invalid')) except TypeError: - raise exceptions.ParseError(_('Date filter needs to be set')) + if query_params.get('last_reported_date', '0') == '0': + raise exceptions.ParseError(_('Date filter needs to be set')) + + return None def get_queryset(self): date = self._extract_date() @@ -76,6 +80,14 @@ def get_queryset(self): queryset = queryset.annotate( date=Value(date, DateField()), ) + # last_reported_date filter is set, a date can only be calucated + # for users with either at least one absence or report + if date is None: + users_with_reports = Report.objects.values('user').distinct() + users_with_absences = Absence.objects.values('user').distinct() + active_users = users_with_reports.union(users_with_absences) + queryset = queryset.filter(id__in=active_users) + queryset = queryset.annotate( pk=Concat( 'id', From 46bbfb6b872107555507c6bcd16da562e60d3298 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 30 Oct 2017 17:27:27 +0100 Subject: [PATCH 299/980] Allow superuser and supervisor to access absences of other users Changing of date, type or deleting of absence is not allowed though. --- timed/permissions.py | 10 + timed/tracking/filters.py | 2 +- timed/tracking/serializers.py | 20 + timed/tracking/tests/test_absence.py | 548 +++++++++++++++------------ timed/tracking/views.py | 30 +- 5 files changed, 359 insertions(+), 251 deletions(-) diff --git a/timed/permissions.py b/timed/permissions.py index 4d84dc743..d51ed327e 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -26,6 +26,16 @@ def has_object_permission(self, request, view, obj): return self.has_permission(request, view) +class IsDeleteOnly(BasePermission): + """Allows only delete method.""" + + def has_permission(self, request, view): + return request.method == 'DELETE' + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + class IsAuthenticated(IsAuthenticated): """ Support mixing permission IsAuthenticated with object permission. diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index f7c4bd95c..bd8916c2b 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -142,4 +142,4 @@ class Meta: """Meta information for the absence filter set.""" model = models.Absence - fields = ['date', 'from_date', 'to_date'] + fields = ['date', 'from_date', 'to_date', 'user'] diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index a7ac0e7f7..d804fba6f 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -212,6 +212,26 @@ def get_duration(self, instance): return duration_string(instance.calculate_duration(employment)) + def validate_date(self, value): + """Only owner is allowed to change date.""" + if self.instance is not None: + user = self.context['request'].user + owner = self.instance.user + if self.instance.date != value and user != owner: + raise ValidationError(_('Only owner may change date')) + + return value + + def validate_type(self, value): + """Only owner is allowed to change type.""" + if self.instance is not None: + user = self.context['request'].user + owner = self.instance.user + if self.instance.date != value and user != owner: + raise ValidationError(_('Only owner may change absence type')) + + return value + def validate(self, data): """Validate the absence data. diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 0f7d4aaf4..4fa89416c 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -1,328 +1,394 @@ -"""Tests for the attendances endpoint.""" - import datetime -from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, - HTTP_401_UNAUTHORIZED) +from rest_framework import status from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, - PublicHolidayFactory) -from timed.employment.models import Employment -from timed.jsonapi_test_case import JSONAPITestCase + PublicHolidayFactory, UserFactory) from timed.tracking.factories import AbsenceFactory, ReportFactory -class AbsenceTests(JSONAPITestCase): - """Tests for the absences endpoint.""" +def test_absence_list_authenticated(auth_client): + absence = AbsenceFactory.create(user=auth_client.user) + url = reverse('absence-list') - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - user = self.user + json = response.json() - other_user = get_user_model().objects.create_user( - username='test', - password='123qweasd' - ) + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(absence.id) - EmploymentFactory.create( - user=user, - start_date=datetime.date(2017, 5, 1), - end_date=None - ) - EmploymentFactory.create( - user=other_user, - start_date=datetime.date(2017, 5, 1), - end_date=None - ) +def test_absence_list_superuser(superadmin_client): + AbsenceFactory.create_batch(2) - self.absences = [ - AbsenceFactory.create(user=user, date=datetime.date(2017, 5, 1)), - AbsenceFactory.create(user=user, date=datetime.date(2017, 5, 2)), - AbsenceFactory.create(user=user, date=datetime.date(2017, 5, 3)) - ] + url = reverse('absence-list') + response = superadmin_client.get(url) + assert response.status_code == status.HTTP_200_OK - AbsenceFactory.create(user=other_user, date=datetime.date(2017, 5, 1)) - AbsenceFactory.create(user=other_user, date=datetime.date(2017, 5, 2)) - AbsenceFactory.create(user=other_user, date=datetime.date(2017, 5, 3)) + json = response.json() + assert len(json['data']) == 2 - def test_absence_list(self): - """Should respond with a list of absences filtered by user.""" - url = reverse('absence-list') - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) +def test_absence_list_supervisor(auth_client): + user = UserFactory.create() + auth_client.user.supervisees.add(user) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + AbsenceFactory.create(user=auth_client.user) + AbsenceFactory.create(user=user) - result = self.result(res) + url = reverse('absence-list') + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 2 - assert len(result['data']) == len(self.absences) - def test_absence_detail(self): - """Should respond with a single absence.""" - absence = self.absences[0] +def test_absence_detail(auth_client): + absence = AbsenceFactory.create(user=auth_client.user) + + url = reverse('absence-detail', args=[ + absence.id + ]) - url = reverse('absence-detail', args=[ - absence.id - ]) + response = auth_client.get(url) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert json['data']['id'] == str(absence.id) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - def test_absence_create(self): - """Should create a new absence.""" - type = AbsenceTypeFactory.create() +def test_absence_create(auth_client): + user = auth_client.user + date = datetime.date(2017, 5, 4) + EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) + type = AbsenceTypeFactory.create() - data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': datetime.date(2017, 5, 4).strftime('%Y-%m-%d') - }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id } } } } + } - url = reverse('absence-list') + url = reverse('absence-list') - noauth_res = self.noauth_client.post(url, data) - res = self.client.post(url, data) + response = auth_client.post(url, data) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_201_CREATED + assert response.status_code == status.HTTP_201_CREATED - result = self.result(res) + json = response.json() + assert json['data']['relationships']['user']['data']['id'] == ( + str(auth_client.user.id) + ) - assert not result['data']['id'] is None - assert ( - int(result['data']['relationships']['user']['data']['id']) == - int(self.user.id) - ) +def test_absence_update_owner(auth_client): + user = auth_client.user + date = datetime.date(2017, 5, 3) + absence = AbsenceFactory.create( + user=auth_client.user, + date=datetime.date(2016, 5, 3) + ) + EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) - def test_absence_update(self): - """Should update and existing absence.""" - absence = self.absences[0] + data = { + 'data': { + 'type': 'absences', + 'id': absence.id, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + } + } + } - absence.date = datetime.date(2017, 5, 5) - absence.save() + url = reverse('absence-detail', args=[ + absence.id + ]) - data = { - 'data': { - 'type': 'absences', - 'id': absence.id, - 'attributes': { - 'date': datetime.date(2017, 5, 8).strftime('%Y-%m-%d') - } + response = auth_client.patch(url, data) + + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert json['data']['attributes']['date'] == '2017-05-03' + + +def test_absence_update_superadmin_date(superadmin_client): + """Test that superadmin may not change date of absence.""" + user = UserFactory.create() + date = datetime.date(2017, 5, 3) + absence = AbsenceFactory.create( + user=user, + date=datetime.date(2016, 5, 3) + ) + EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) + + data = { + 'data': { + 'type': 'absences', + 'id': absence.id, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') } } + } + + url = reverse('absence-detail', args=[ + absence.id + ]) + + response = superadmin_client.patch(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST - url = reverse('absence-detail', args=[ - absence.id - ]) - - noauth_res = self.noauth_client.patch(url, data) - res = self.client.patch(url, data) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - assert ( - result['data']['attributes']['date'] == - data['data']['attributes']['date'] - ) - - def test_absence_delete(self): - """Should delete an absence.""" - absence = self.absences[0] - - url = reverse('absence-detail', args=[ - absence.id - ]) - - noauth_res = self.noauth_client.delete(url) - res = self.client.delete(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_204_NO_CONTENT - - def test_absence_fill_worktime(self): - """Should create an absence which fills the worktime.""" - date = datetime.date(2017, 5, 10) - type = AbsenceTypeFactory.create(fill_worktime=True) - employment = Employment.objects.get_at(self.user, date) - - employment.worktime_per_day = datetime.timedelta(hours=8) - employment.save() - - ReportFactory.create( - user=self.user, - date=date, - duration=datetime.timedelta(hours=5) - ) - - data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') - }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } + +def test_absence_update_superadmin_type(superadmin_client): + """Test that superadmin may not change type of absence.""" + user = UserFactory.create() + date = datetime.date(2017, 5, 3) + type = AbsenceTypeFactory.create() + absence = AbsenceFactory.create( + user=user, + date=datetime.date(2016, 5, 3) + ) + EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) + + data = { + 'data': { + 'type': 'absences', + 'id': absence.id, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id } } } } + } + + url = reverse('absence-detail', args=[ + absence.id + ]) + + response = superadmin_client.patch(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_absence_delete_owner(auth_client): + absence = AbsenceFactory.create(user=auth_client.user) - url = reverse('absence-list') + url = reverse('absence-detail', args=[absence.id]) - res = self.client.post(url, data) + response = auth_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT - assert res.status_code == HTTP_201_CREATED - result = self.result(res) +def test_absence_delete_superuser(superadmin_client): + """Test that superuser may not delete absences of other users.""" + user = UserFactory.create() + absence = AbsenceFactory.create(user=user) - assert result['data']['attributes']['duration'] == '03:00:00' + url = reverse('absence-detail', args=[absence.id]) - def test_absence_fill_worktime_reported_time_to_long(self): - """ - Verify absence fill worktime is zero when reported time is too long. + response = superadmin_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN - Too long is defined when reported time is longer than worktime per day. - """ - date = datetime.date(2017, 5, 10) - type = AbsenceTypeFactory.create(fill_worktime=True) - employment = Employment.objects.get_at(self.user, date) - employment.worktime_per_day = datetime.timedelta(hours=8) - employment.save() +def test_absence_fill_worktime(auth_client): + """Should create an absence which fills the worktime.""" + date = datetime.date(2017, 5, 10) + user = auth_client.user + EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) + type = AbsenceTypeFactory.create(fill_worktime=True) - ReportFactory.create( - user=self.user, - date=date, - duration=datetime.timedelta(hours=8, minutes=30) - ) + ReportFactory.create( + user=user, + date=date, + duration=datetime.timedelta(hours=5) + ) - data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') - }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id } } } } + } - url = reverse('absence-list') + url = reverse('absence-list') - res = self.client.post(url, data) + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED - assert res.status_code == HTTP_201_CREATED + json = response.json() + assert json['data']['attributes']['duration'] == '03:00:00' - result = self.result(res) - assert result['data']['attributes']['duration'] == '00:00:00' +def test_absence_fill_worktime_reported_time_to_long(auth_client): + """ + Verify absence fill worktime is zero when reported time is too long. - def test_absence_weekend(self): - """Should not be able to create an absence on a weekend.""" - type = AbsenceTypeFactory.create() + Too long is defined when reported time is longer than worktime per day. + """ + date = datetime.date(2017, 5, 10) + user = auth_client.user + EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) + type = AbsenceTypeFactory.create(fill_worktime=True) - data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': datetime.date(2017, 5, 14).strftime('%Y-%m-%d') - }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } + ReportFactory.create( + user=user, + date=date, + duration=datetime.timedelta(hours=8, minutes=30) + ) + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id } } } } + } - url = reverse('absence-list') - - res = self.client.post(url, data) + url = reverse('absence-list') - assert res.status_code == HTTP_400_BAD_REQUEST + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED - def test_absence_public_holiday(self): - """Should not be able to create an absence on a public holiday.""" - date = datetime.date(2017, 5, 16) + json = response.json() + assert json['data']['attributes']['duration'] == '00:00:00' - type = AbsenceTypeFactory.create() - PublicHolidayFactory.create( - location=Employment.objects.get_at(self.user, date).location, - date=date - ) +def test_absence_weekend(auth_client): + """Should not be able to create an absence on a weekend.""" + date = datetime.date(2017, 5, 14) + user = auth_client.user + type = AbsenceTypeFactory.create() + EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) - data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') - }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id } } } } + } - url = reverse('absence-list') + url = reverse('absence-list') - res = self.client.post(url, data) + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST - assert res.status_code == HTTP_400_BAD_REQUEST + +def test_absence_public_holiday(auth_client): + """Should not be able to create an absence on a public holiday.""" + date = datetime.date(2017, 5, 16) + user = auth_client.user + type = AbsenceTypeFactory.create() + employment = EmploymentFactory.create( + user=user, + start_date=date, + worktime_per_day=datetime.timedelta(hours=8) + ) + PublicHolidayFactory.create(location=employment.location, date=date) + + data = { + 'data': { + 'type': 'absences', + 'id': None, + 'attributes': { + 'date': date.strftime('%Y-%m-%d') + }, + 'relationships': { + 'type': { + 'data': { + 'type': 'absence-types', + 'id': type.id + } + } + } + } + } + + url = reverse('absence-list') + + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_absence_create_unemployed(auth_client): """Test creation of absence fails on unemployed day.""" - date = datetime.date(2017, 5, 16) type = AbsenceTypeFactory.create() data = { @@ -330,7 +396,7 @@ def test_absence_create_unemployed(auth_client): 'type': 'absences', 'id': None, 'attributes': { - 'date': date.strftime('%Y-%m-%d') + 'date': '2017-05-16' }, 'relationships': { 'type': { @@ -345,8 +411,8 @@ def test_absence_create_unemployed(auth_client): url = reverse('absence-list') - res = auth_client.post(url, data) - assert res.status_code == HTTP_400_BAD_REQUEST + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_absence_detail_unemployed(auth_client): @@ -356,7 +422,7 @@ def test_absence_detail_unemployed(auth_client): url = reverse('absence-detail', args=[absence.id]) res = auth_client.get(url) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK json = res.json() assert json['data']['attributes']['duration'] == '00:00:00' diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 70df1aaa7..3c72dd07c 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,14 +1,15 @@ """Viewsets for the tracking app.""" import django_excel +from django.db.models import Q from django.http import HttpResponseBadRequest from rest_condition import C from rest_framework.decorators import list_route from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from timed.permissions import (IsAdminUser, IsAuthenticated, IsOwner, - IsReadOnly, IsUnverified) +from timed.permissions import (IsAdminUser, IsAuthenticated, IsDeleteOnly, + IsOwner, IsReadOnly, IsSuperUser, IsUnverified) from timed.tracking import filters, models, serializers @@ -204,15 +205,26 @@ class AbsenceViewSet(ModelViewSet): serializer_class = serializers.AbsenceSerializer filter_class = filters.AbsenceFilterSet + permission_classes = [ + # superuser can change all but not delete + C(IsAuthenticated) & C(IsSuperUser) & ~C(IsDeleteOnly) | + # owner may change all its absences + C(IsAuthenticated) & C(IsOwner) | + # all authenticated users may read filtered result + C(IsAuthenticated) & C(IsReadOnly) + ] + def get_queryset(self): - """Filter the queryset by the user of the request. + user = self.request.user - :return: The filtered absences - :rtype: QuerySet - """ - return models.Absence.objects.select_related( + queryset = models.Absence.objects.select_related( 'type', 'user' - ).filter( - user=self.request.user ) + + if not user.is_superuser: + queryset = queryset.filter( + Q(user=user) | Q(user__supervisors=user) + ) + + return queryset From da956734ccebf33e30ed808581649ac8359faf65 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 31 Oct 2017 11:06:55 +0100 Subject: [PATCH 300/980] Do not allow admin user to delete reports of other users --- timed/permissions.py | 2 +- timed/tracking/tests/test_report.py | 9 +++++++++ timed/tracking/views.py | 6 +++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/timed/permissions.py b/timed/permissions.py index d51ed327e..30927738f 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -66,5 +66,5 @@ class IsSuperUser(BasePermission): def has_permission(self, request, view): return request.user and request.user.is_superuser - def has_object_permission(self, request, view, obj): # pragma: todo cover + def has_object_permission(self, request, view, obj): return self.has_permission(request, view) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index bb07865e9..0269d0924 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -504,6 +504,15 @@ def test_report_list_no_result(admin_client): assert json['meta']['total-time'] == '00:00:00' +def test_report_delete_superuser(superadmin_client): + """Test that superuser may not delete reports of other users.""" + report = ReportFactory.create() + url = reverse('report-detail', args=[report.id]) + + response = superadmin_client.delete(url) + assert response.status_code == HTTP_403_FORBIDDEN + + def test_report_list_filter_cost_center(auth_client): cost_center = CostCenterFactory.create() # 1st valid case: report with task of given cost center diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 3c72dd07c..8395b54d1 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -81,8 +81,8 @@ class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet permission_classes = [ - # admin user can change all - C(IsAuthenticated) & C(IsAdminUser) | + # admin user can change all but not delete + C(IsAuthenticated) & C(IsAdminUser) & ~C(IsDeleteOnly) | # owner may only change its own unverified reports C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) | # all authenticated users may read all reports @@ -209,7 +209,7 @@ class AbsenceViewSet(ModelViewSet): # superuser can change all but not delete C(IsAuthenticated) & C(IsSuperUser) & ~C(IsDeleteOnly) | # owner may change all its absences - C(IsAuthenticated) & C(IsOwner) | + C(IsAuthenticated) & C(IsOwner) | # all authenticated users may read filtered result C(IsAuthenticated) & C(IsReadOnly) ] From 38b35cd40b4b2a901a928d656173e046b9515fe4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 31 Oct 2017 11:28:39 +0100 Subject: [PATCH 301/980] Add comment to overtime credit Makes it consistent to absence credit --- .../migrations/0010_overtimecredit_comment.py | 20 +++++++++++++++++++ timed/employment/models.py | 1 + timed/employment/serializers.py | 3 ++- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 timed/employment/migrations/0010_overtimecredit_comment.py diff --git a/timed/employment/migrations/0010_overtimecredit_comment.py b/timed/employment/migrations/0010_overtimecredit_comment.py new file mode 100644 index 000000000..459692287 --- /dev/null +++ b/timed/employment/migrations/0010_overtimecredit_comment.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-31 10:25 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0009_delete_userabsencetype'), + ] + + operations = [ + migrations.AddField( + model_name='overtimecredit', + name='comment', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index cf6100163..ead4d98fc 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -111,6 +111,7 @@ class OvertimeCredit(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='overtime_credits') + comment = models.CharField(max_length=255, blank=True) date = models.DateField() duration = models.DurationField(default=timedelta(0)) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index c537430b7..e24016844 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -373,5 +373,6 @@ class Meta: fields = [ 'user', 'date', - 'duration' + 'duration', + 'comment', ] From 61496c1b5e6939f8e7cdb72219f5ffdc39a03064 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 31 Oct 2017 16:09:01 +0100 Subject: [PATCH 302/980] Allow superuser and supervisor to update user Superuser may additionally delete users without reports and create new ones. --- timed/employment/models.py | 5 + timed/employment/permissions.py | 6 + timed/employment/serializers.py | 35 ++--- timed/employment/tests/test_user.py | 191 ++++++++++++++-------------- timed/employment/views.py | 27 +++- timed/permissions.py | 41 +++++- 6 files changed, 175 insertions(+), 130 deletions(-) create mode 100644 timed/employment/permissions.py diff --git a/timed/employment/models.py b/timed/employment/models.py index ead4d98fc..9052677c5 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -312,6 +312,11 @@ class User(AbstractUser): objects = UserManager() + @property + def user_id(self): + """Map to id to be able to use generic permissions.""" + return self.id + def calculate_worktime(self, start, end): """Calculate reported, expected and balance for user. diff --git a/timed/employment/permissions.py b/timed/employment/permissions.py new file mode 100644 index 000000000..218b5f4d9 --- /dev/null +++ b/timed/employment/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class NoReports(BasePermission): + def has_object_permission(self, request, view, obj): + return not obj.reports.exists() diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index e24016844..dca701990 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -7,7 +7,6 @@ from django.db.models.functions import Coalesce from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ -from rest_framework.exceptions import PermissionDenied from rest_framework_json_api import relations from rest_framework_json_api.serializers import (ModelSerializer, Serializer, SerializerMethodField, @@ -18,18 +17,6 @@ class UserSerializer(ModelSerializer): - """User serializer.""" - - employments = relations.ResourceRelatedField(many=True, read_only=True) - - def validate(self, data): - user = self.context['request'].user - - # users may only change their own profile - if self.instance.id != user.id: - raise PermissionDenied() - - return data included_serializers = { 'supervisors': @@ -43,26 +30,28 @@ class Meta: model = get_user_model() fields = [ - 'username', - 'first_name', - 'last_name', 'email', 'employments', + 'first_name', + 'is_active', 'is_staff', 'is_superuser', - 'is_active', - 'tour_done', + 'last_name', + 'supervisees', 'supervisors', - 'supervisees' + 'tour_done', + 'username', ] read_only_fields = [ - 'username', + 'employments', 'first_name', - 'last_name', - 'is_staff', 'is_active', + 'is_staff', + 'is_superuser', + 'last_name', + 'supervisees', 'supervisors', - 'supervisees' + 'username', ] diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 5319230b0..e4470e539 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -2,133 +2,138 @@ from django.core.urlresolvers import reverse from rest_framework import status -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) -from timed.employment.factories import EmploymentFactory, UserFactory -from timed.jsonapi_test_case import JSONAPITestCase +from timed.employment.factories import UserFactory +from timed.tracking.factories import ReportFactory -class UserTests(JSONAPITestCase): - """Tests for the user endpoint. +def test_user_list_unauthenticated(client): + url = reverse('user-list') + response = client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED - This endpoint should be read only for normal users. - """ - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() +def test_user_update_unauthenticated(client): + user = UserFactory.create() + url = reverse('user-detail', args=[user.id]) + response = client.patch(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED - self.users = UserFactory.create_batch(3) - for user in self.users + [self.user]: - EmploymentFactory.create(user=user) +def test_user_list(auth_client, django_assert_num_queries): + UserFactory.create_batch(2) - def test_user_list(self): - """Should respond with a list of all users.""" - url = reverse('user-list') + url = reverse('user-list') - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + with django_assert_num_queries(5): + response = auth_client.get(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + assert response.status_code == status.HTTP_200_OK - result = self.result(res) + json = response.json() + assert len(json['data']) == 3 - assert len(result['data']) == 4 - assert int(result['data'][0]['id']) == self.user.id - def test_logged_in_user_detail(self): - """Should respond with a single user. +def test_user_detail(auth_client): + user = auth_client.user - This should only work if it is the currently logged in user. - """ - url = reverse('user-detail', args=[ - self.user.id - ]) + url = reverse('user-detail', args=[user.id]) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - def test_not_logged_in_user_detail(self): - """Should return other users too.""" - url = reverse('user-detail', args=[ - self.users[0].id - ]) +def test_user_create_authenticated(auth_client): + url = reverse('user-list') - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + response = auth_client.post(url) + assert response.status_code == status.HTTP_403_FORBIDDEN - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - def test_user_create(self): - """Should not be able to create a new user.""" - url = reverse('user-list') +def test_user_create_superuser(superadmin_client): + url = reverse('user-list') - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) + data = { + 'data': { + 'type': 'users', + 'id': None, + 'attributes': { + 'is_staff': True, + 'tour_done': True, + 'email': 'test@example.net', + 'first_name': 'First name', + 'last_name': 'Last name', + }, + } + } + + response = superadmin_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED + + +def test_user_update_owner(auth_client): + user = auth_client.user + data = { + 'data': { + 'type': 'users', + 'id': user.id, + 'attributes': { + 'is_staff': True, + 'tour_done': True + }, + } + } - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + url = reverse('user-detail', args=[ + user.id + ]) - def test_user_update_self(self): - """User may only change self.""" - user = self.user - user.is_staff = False - user.save() + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK - data = { - 'data': { - 'type': 'users', - 'id': user.id, - 'attributes': { - 'is_staff': True, - 'tour_done': True - }, - } - } + user.refresh_from_db() + assert user.tour_done + assert not user.is_staff + + +def test_user_update_other(auth_client): + """User may not change other user.""" + user = UserFactory.create() + url = reverse('user-detail', args=[user.id]) + res = auth_client.patch(url) + + assert res.status_code == status.HTTP_403_FORBIDDEN + + +def test_user_delete_authenticated(auth_client): + """Should not be able delete a user.""" + user = auth_client.user - url = reverse('user-detail', args=[ - user.id - ]) + url = reverse('user-detail', args=[user.id]) - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url, data=data) + response = auth_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == status.HTTP_200_OK - user.refresh_from_db() - assert self.user.tour_done - assert not self.user.is_staff +def test_user_delete_superuser(superadmin_client): + """Should not be able delete a user.""" + user = UserFactory.create() - def test_user_update_other(self): - """User may not change other user.""" - url = reverse('user-detail', args=[ - self.users[0].id - ]) - res = self.client.patch(url) + url = reverse('user-detail', args=[user.id]) - assert res.status_code == status.HTTP_403_FORBIDDEN + response = superadmin_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT - def test_user_delete(self): - """Should not be able delete a user.""" - user = self.user - url = reverse('user-detail', args=[ - user.id - ]) +def test_user_delete_with_reports_superuser(superadmin_client): + """Test that user with reports may not be deleted.""" + user = UserFactory.create() + ReportFactory.create(user=user) - noauth_res = self.noauth_client.delete(url) - res = self.client.delete(url) + url = reverse('user-detail', args=[user.id]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = superadmin_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN def test_user_supervisor_filter(auth_client): diff --git a/timed/employment/views.py b/timed/employment/views.py index b0fb57a85..132856beb 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -6,19 +6,19 @@ from django.db.models.functions import Concat from django.utils.translation import ugettext_lazy as _ from rest_condition import C -from rest_framework import exceptions, mixins, viewsets +from rest_framework import exceptions from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from timed.employment import filters, models, serializers +from timed.employment.permissions import NoReports from timed.mixins import AggregateQuerysetMixin -from timed.permissions import IsAuthenticated, IsReadOnly, IsSuperUser +from timed.permissions import (IsAuthenticated, IsCreateOnly, IsDeleteOnly, + IsOwner, IsReadOnly, IsSuperUser, IsSupervisor, + IsUpdateOnly) from timed.tracking.models import Absence, Report -class UserViewSet(mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): +class UserViewSet(ModelViewSet): """ Expose user actions. @@ -26,12 +26,25 @@ class UserViewSet(mixins.RetrieveModelMixin, only allows retrieving and updating. """ + permission_classes = [ + # only owner, superuser and supervisor may update user + (C(IsOwner) | C(IsSuperUser) | C(IsSupervisor)) & C(IsUpdateOnly) | + # only superuser may delete users without reports + C(IsSuperUser) & C(IsDeleteOnly) & C(NoReports) | + # only superuser may create users + C(IsSuperUser) & C(IsCreateOnly) | + # all authenticated users may read + C(IsAuthenticated) & C(IsReadOnly) + ] + serializer_class = serializers.UserSerializer filter_class = filters.UserFilterSet search_fields = ('username', 'first_name', 'last_name') def get_queryset(self): - return get_user_model().objects.prefetch_related('employments') + return get_user_model().objects.prefetch_related( + 'employments', 'supervisees', 'supervisors' + ) class WorktimeBalanceViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): diff --git a/timed/permissions.py b/timed/permissions.py index 30927738f..dc3f6c5ea 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -2,13 +2,6 @@ IsAdminUser, IsAuthenticated) -class IsOwner(BasePermission): - """Allows access to object only to owners.""" - - def has_object_permission(self, request, view, obj): - return obj.user_id == request.user.id - - class IsUnverified(BasePermission): """Allows access only to verified objects.""" @@ -36,6 +29,26 @@ def has_object_permission(self, request, view, obj): return self.has_permission(request, view) +class IsCreateOnly(BasePermission): + """Allows only create method.""" + + def has_permission(self, request, view): + return request.method == 'POST' + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + +class IsUpdateOnly(BasePermission): + """Allows only update method.""" + + def has_permission(self, request, view): + return request.method in ['PATCH', 'PUT'] + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + class IsAuthenticated(IsAuthenticated): """ Support mixing permission IsAuthenticated with object permission. @@ -48,6 +61,20 @@ def has_object_permission(self, request, view, obj): return self.has_permission(request, view) +class IsOwner(IsAuthenticated): + """Allows access to object only to owners.""" + + def has_object_permission(self, request, view, obj): + return obj.user_id == request.user.id + + +class IsSupervisor(IsAuthenticated): + """Allows access to object only to supervisors.""" + + def has_object_permission(self, request, view, obj): + return request.user.supervisees.filter(id=obj.user_id).exists() + + class IsAdminUser(IsAdminUser): """ Support mixing permission IsAdminUser with object permission. From dabdb5f8f551029c5617f9e8b5619a167e3e83d7 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Tue, 31 Oct 2017 17:25:22 +0100 Subject: [PATCH 303/980] Add missing filters for user profile resposibility view --- timed/employment/filters.py | 3 ++- timed/projects/filters.py | 1 + timed/projects/views.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 2aeb03db7..15291697e 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -39,7 +39,7 @@ class Meta: class UserFilterSet(FilterSet): active = NumberFilter(name='is_active') - supervisor = Filter(name='supervisors__id', lookup_expr='contains') + supervisor = NumberFilter(name='supervisors') class Meta: model = models.User @@ -90,6 +90,7 @@ class Meta: class WorktimeBalanceFilterSet(FilterSet): user = NumberFilter(name='id') + supervisor = NumberFilter(name='supervisors') # additional filters analyzed in WorktimeBalanceView # date = DateFilter() # last_reported_date = NumberFilter() diff --git a/timed/projects/filters.py b/timed/projects/filters.py index c615ce9e2..0b9335079 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -26,6 +26,7 @@ class ProjectFilterSet(FilterSet): """Filter set for the projects endpoint.""" archived = NumberFilter(name='archived') + reviewer = NumberFilter(name='reviewers') class Meta: """Meta information for the project filter set.""" diff --git a/timed/projects/views.py b/timed/projects/views.py index 7ea40792c..f8b514587 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -44,6 +44,7 @@ class ProjectViewSet(ReadOnlyModelViewSet): serializer_class = serializers.ProjectSerializer filter_class = filters.ProjectFilterSet + ordering_fields = ('customer__name', 'name',) ordering = 'name' def get_queryset(self): From bf208b09b0edf47f6310b0a9dc212d7b67d7281a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 1 Nov 2017 13:06:24 +0100 Subject: [PATCH 304/980] Add transfer action to user This transfer absence and worktime balances from last year to current year adding credits when needed. --- .../migrations/0011_auto_20171101_1227.py | 25 +++++++++ timed/employment/models.py | 45 +++++++++++++++ timed/employment/serializers.py | 33 ++++------- timed/employment/tests/test_user.py | 44 ++++++++++++++- timed/employment/views.py | 55 ++++++++++++++++++- 5 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 timed/employment/migrations/0011_auto_20171101_1227.py diff --git a/timed/employment/migrations/0011_auto_20171101_1227.py b/timed/employment/migrations/0011_auto_20171101_1227.py new file mode 100644 index 000000000..f84f76d44 --- /dev/null +++ b/timed/employment/migrations/0011_auto_20171101_1227.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-01 11:27 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0010_overtimecredit_comment'), + ] + + operations = [ + migrations.AddField( + model_name='absencecredit', + name='transfer', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='overtimecredit', + name='transfer', + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 9052677c5..037de1c0e 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -11,6 +11,7 @@ from django.utils.translation import ugettext_lazy as _ from timed.models import WeekdaysField +from timed.tracking.models import Absence class Location(models.Model): @@ -82,6 +83,42 @@ def __str__(self): """ return self.name + def calculate_credit(self, user, start, end): + """ + Calculate approved days of type for user in given time frame. + + For absence types which fill worktime this will be None. + """ + if self.fill_worktime: + return None + + credits = AbsenceCredit.objects.filter( + user=user, + absence_type=self, + date__range=[start, end] + ) + data = credits.aggregate(credit=Sum('days')) + credit = data['credit'] or 0 + + return credit + + def calculate_used_days(self, user, start, end): + """ + Calculate used days of type for user in given time frame. + + For absence types which fill worktime this will be None. + """ + if self.fill_worktime: + return None + + absences = Absence.objects.filter( + user=user, + type=self, + date__range=[start, end] + ) + used_days = absences.count() + return used_days + class Meta: ordering = ('name', ) @@ -100,6 +137,10 @@ class AbsenceCredit(models.Model): absence_type = models.ForeignKey(AbsenceType) date = models.DateField() days = models.IntegerField(default=0) + transfer = models.BooleanField(default=False) + """ + Mark whether this absence credit is a transfer from last year. + """ class OvertimeCredit(models.Model): @@ -114,6 +155,10 @@ class OvertimeCredit(models.Model): comment = models.CharField(max_length=255, blank=True) date = models.DateField() duration = models.DurationField(default=timedelta(0)) + transfer = models.BooleanField(default=False) + """ + Mark whether this absence credit is a transfer from last year. + """ class EmploymentManager(models.Manager): diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index dca701990..f46fe33cd 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -3,7 +3,7 @@ from datetime import date, timedelta from django.contrib.auth import get_user_model -from django.db.models import Max, Sum, Value +from django.db.models import Max, Value from django.db.models.functions import Coalesce from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ @@ -130,21 +130,14 @@ def get_credit(self, instance): # id is mapped to absence type absence_type = instance.id - if absence_type.fill_worktime: - return None start = self._get_start(instance) - credits = models.AbsenceCredit.objects.filter( - user=instance.user, - absence_type=absence_type, - date__range=[start, instance.date] - ) - data = credits.aggregate(credit=Sum('days')) - credit = data['credit'] or 0 # avoid multiple calculations as get_balance needs it as well - instance['credit'] = credit - return credit + instance['credit'] = absence_type.calculate_credit( + instance.user, start, instance.date + ) + return instance['credit'] def get_used_days(self, instance): """ @@ -157,20 +150,14 @@ def get_used_days(self, instance): # id is mapped to absence type absence_type = instance.id - if absence_type.fill_worktime: - return None start = self._get_start(instance) - absences = Absence.objects.filter( - user=instance.user, - type=absence_type, - date__range=[start, instance.date] - ) - used_days = absences.count() # avoid multiple calculations as get_balance needs it as well - instance['used_days'] = used_days - return used_days + instance['used_days'] = absence_type.calculate_used_days( + instance.user, start, instance.date + ) + return instance['used_days'] def get_used_duration(self, instance): """ @@ -353,6 +340,7 @@ class Meta: 'date', 'days', 'comment', + 'transfer' ] @@ -364,4 +352,5 @@ class Meta: 'date', 'duration', 'comment', + 'transfer' ] diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index e4470e539..0176f1f1e 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -1,10 +1,12 @@ -"""Tests for the locations endpoint.""" +from datetime import date, timedelta +import pytest from django.core.urlresolvers import reverse from rest_framework import status -from timed.employment.factories import UserFactory -from timed.tracking.factories import ReportFactory +from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, + UserFactory) +from timed.tracking.factories import AbsenceFactory, ReportFactory def test_user_list_unauthenticated(client): @@ -150,3 +152,39 @@ def test_user_supervisor_filter(auth_client): }) assert len(res.json()['data']) == 5 + + +@pytest.mark.freeze_time('2018-01-07') +def test_user_transfer(superadmin_client): + user = UserFactory.create() + EmploymentFactory.create( + user=user, start_date=date(2017, 12, 28), percentage=100 + ) + AbsenceTypeFactory.create(fill_worktime=True) + AbsenceTypeFactory.create(fill_worktime=False) + absence_type = AbsenceTypeFactory.create(fill_worktime=False) + AbsenceFactory.create( + user=user, type=absence_type, date=date(2017, 12, 29) + ) + + url = reverse('user-transfer', args=[user.id]) + response = superadmin_client.post(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # running transfer twice should lead to same result + response = superadmin_client.post(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + assert user.overtime_credits.count() == 1 + overtime_credit = user.overtime_credits.first() + assert overtime_credit.transfer + assert overtime_credit.date == date(2018, 1, 1) + assert overtime_credit.duration == timedelta(hours=-8, minutes=-30) + assert overtime_credit.comment == 'Transfer 2017' + + assert user.absence_credits.count() == 1 + absence_credit = user.absence_credits.first() + assert absence_credit.transfer + assert absence_credit.date == date(2018, 1, 1) + assert absence_credit.days == -1 + assert absence_credit.comment == 'Transfer 2017' diff --git a/timed/employment/views.py b/timed/employment/views.py index 132856beb..52f237254 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -6,7 +6,9 @@ from django.db.models.functions import Concat from django.utils.translation import ugettext_lazy as _ from rest_condition import C -from rest_framework import exceptions +from rest_framework import exceptions, status +from rest_framework.decorators import detail_route +from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from timed.employment import filters, models, serializers @@ -46,6 +48,57 @@ def get_queryset(self): 'employments', 'supervisees', 'supervisors' ) + @detail_route(methods=['post']) + def transfer(self, request, pk=None): + """ + Transfer worktime and absence balance to new year. + + It will skip any credits if a credit already exists on the first + of the new year. + """ + user = self.get_object() + + year = datetime.date.today().year + start_year = datetime.date(year, 1, 1) + start = datetime.date(year - 1, 1, 1) + end = datetime.date(year - 1, 12, 31) + + # transfer absence types + transfered_absence_credits = user.absence_credits.filter( + date=start_year, transfer=True) + types = models.AbsenceType.objects.filter( + fill_worktime=False + ).exclude(id__in=transfered_absence_credits.values('absence_type')) + for absence_type in types: + credit = absence_type.calculate_credit(user, start, end) + used_days = absence_type.calculate_used_days(user, start, end) + balance = credit - used_days + if balance != 0: + models.AbsenceCredit.objects.create( + absence_type=absence_type, + user=user, + comment=_('Transfer %(year)s') % {'year': year - 1}, + date=start_year, + days=balance, + transfer=True + ) + + # transfer overtime + overtime_credit = user.overtime_credits.filter( + date=start_year, transfer=True + ) + if not overtime_credit.exists(): + reported, expected, delta = user.calculate_worktime(start, end) + models.OvertimeCredit.objects.create( + user=user, + comment=_('Transfer %(year)s') % {'year': year - 1}, + date=start_year, + duration=delta, + transfer=True + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + class WorktimeBalanceViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Calculate worktime for different user on different dates.""" From 3abd8b7aead08f554ecf4b864832e0572793c5a0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 Nov 2017 10:14:57 +0100 Subject: [PATCH 305/980] Move all tests to pytest --- timed/conftest.py | 18 + timed/employment/tests/test_absence_type.py | 96 +-- timed/employment/tests/test_location.py | 105 +-- timed/employment/tests/test_public_holiday.py | 120 ++- timed/jsonapi_test_case.py | 26 +- timed/projects/tests/test_customer.py | 102 +-- timed/projects/tests/test_project.py | 113 ++- timed/projects/tests/test_task.py | 206 +++--- timed/tracking/tests/test_activity.py | 284 +++---- timed/tracking/tests/test_activity_block.py | 184 ++--- timed/tracking/tests/test_attendance.py | 163 ++-- timed/tracking/tests/test_report.py | 694 +++++++++--------- 12 files changed, 856 insertions(+), 1255 deletions(-) diff --git a/timed/conftest.py b/timed/conftest.py index 4c09827f1..7b1acfab8 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -27,6 +27,24 @@ def auth_client(db): return client +@pytest.fixture +def admin_client(db): + """Return instance of a JSONAPIClient that is logged in as a staff user.""" + user = get_user_model().objects.create_user( + username='user', + password='123qweasd', + first_name='Test', + last_name='User', + is_superuser=False, + is_staff=True + ) + + client = JSONAPIClient() + client.user = user + client.login('user', '123qweasd') + return client + + @pytest.fixture def superadmin_client(db): """Return instance of a JSONAPIClient that is logged in as superuser.""" diff --git a/timed/employment/tests/test_absence_type.py b/timed/employment/tests/test_absence_type.py index 7247d0303..8f01a9e3f 100644 --- a/timed/employment/tests/test_absence_type.py +++ b/timed/employment/tests/test_absence_type.py @@ -1,87 +1,57 @@ -"""Tests for the absence types endpoint.""" - from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status from timed.employment.factories import AbsenceTypeFactory -from timed.jsonapi_test_case import JSONAPITestCase - - -class AbsenceTypeTests(JSONAPITestCase): - """Tests for the absence types endpoint. - - This endpoint should be read only for normal users. - """ - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - self.absence_types = AbsenceTypeFactory.create_batch(5) - def test_absence_type_list(self): - """Should respond with a list of absence types.""" - url = reverse('absence-type-list') +def test_absence_type_list(auth_client): + AbsenceTypeFactory.create_batch(2) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + url = reverse('absence-type-list') - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - result = self.result(res) + json = response.json() + assert len(json['data']) == 2 - assert len(result['data']) == len(self.absence_types) - def test_absence_type_detail(self): - """Should respond with a single absence type.""" - absence_type = self.absence_types[0] +def test_absence_type_detail(auth_client): + absence_type = AbsenceTypeFactory.create() - url = reverse('absence-type-detail', args=[ - absence_type.id - ]) + url = reverse('absence-type-detail', args=[ + absence_type.id + ]) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + response = auth_client.get(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + assert response.status_code == status.HTTP_200_OK - def test_absence_type_create(self): - """Should not be able to create a new absence type.""" - url = reverse('absence-type-list') - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) +def test_absence_type_create(auth_client): + url = reverse('absence-type-list') - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - def test_absence_type_update(self): - """Should not be able to update an existing absence type.""" - absence_type = self.absence_types[0] - url = reverse('absence-type-detail', args=[ - absence_type.id - ]) +def test_absence_type_update(auth_client): + absence_type = AbsenceTypeFactory.create() - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) + url = reverse('absence-type-detail', args=[ + absence_type.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.patch(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - def test_absence_type_delete(self): - """Should not be able delete an absence type.""" - absence_type = self.absence_types[0] - url = reverse('absence-type-detail', args=[ - absence_type.id - ]) +def test_absence_type_delete(auth_client): + absence_type = AbsenceTypeFactory.create() - noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) + url = reverse('absence-type-detail', args=[ + absence_type.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.delete(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_location.py b/timed/employment/tests/test_location.py index 2570011b1..0fdd33d41 100644 --- a/timed/employment/tests/test_location.py +++ b/timed/employment/tests/test_location.py @@ -1,91 +1,58 @@ -"""Tests for the locations endpoint.""" - from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status from timed.employment.factories import LocationFactory -from timed.jsonapi_test_case import JSONAPITestCase - - -class LocationTests(JSONAPITestCase): - """Tests for the location endpoint. - - This endpoint should be read only for normal users. - """ - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - - self.locations = LocationFactory.create_batch(3) - - def test_location_list(self): - """Should respond with a list of locations.""" - url = reverse('location-list') - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK +def test_location_list(auth_client): + LocationFactory.create() + url = reverse('location-list') - result = self.result(res) - data = result['data'] + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - assert len(data) == len(self.locations) - assert data[0]['attributes']['workdays'] == ( - [str(day) for day in range(1, 6)] - ) + data = response.json()['data'] + assert len(data) == 1 + assert data[0]['attributes']['workdays'] == ( + [str(day) for day in range(1, 6)] + ) - def test_location_detail(self): - """Should respond with a single location.""" - location = self.locations[0] - url = reverse('location-detail', args=[ - location.id - ]) +def test_location_detail(auth_client): + location = LocationFactory.create() - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + url = reverse('location-detail', args=[ + location.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - def test_location_create(self): - """Should not be able to create a new location.""" - url = reverse('location-list') - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) +def test_location_create(auth_client): + url = reverse('location-list') - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - def test_location_update(self): - """Should not be able to update an existing location.""" - location = self.locations[0] - url = reverse('location-detail', args=[ - location.id - ]) +def test_location_update(auth_client): + location = LocationFactory.create() - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) + url = reverse('location-detail', args=[ + location.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.patch(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - def test_location_delete(self): - """Should not be able delete a location.""" - location = self.locations[0] - url = reverse('location-detail', args=[ - location.id - ]) +def test_location_delete(auth_client): + location = LocationFactory.create() - noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) + url = reverse('location-detail', args=[ + location.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.delete(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_public_holiday.py b/timed/employment/tests/test_public_holiday.py index 7bc62509c..88fafcfa9 100644 --- a/timed/employment/tests/test_public_holiday.py +++ b/timed/employment/tests/test_public_holiday.py @@ -1,105 +1,71 @@ -"""Tests for the public holidays endpoint.""" +from datetime import date from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status from timed.employment.factories import PublicHolidayFactory -from timed.employment.models import PublicHoliday -from timed.jsonapi_test_case import JSONAPITestCase -class PublicHolidayTests(JSONAPITestCase): - """Tests for the public holiday endpoint. +def test_public_holiday_list(auth_client): + PublicHolidayFactory.create() + url = reverse('public-holiday-list') - This endpoint should be read only for normal users. - """ + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() + json = response.json() + assert len(json['data']) == 1 - self.public_holidays = PublicHolidayFactory.create_batch(10) - def test_public_holiday_list(self): - """Should respond with a list of public holidays.""" - url = reverse('public-holiday-list') +def test_public_holiday_detail(auth_client): + public_holiday = PublicHolidayFactory.create() - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + url = reverse('public-holiday-detail', args=[ + public_holiday.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - result = self.result(res) - assert len(result['data']) == len(self.public_holidays) +def test_public_holiday_create(auth_client): + url = reverse('public-holiday-list') - def test_public_holiday_detail(self): - """Should respond with a single public holiday.""" - public_holiday = self.public_holidays[0] + response = auth_client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - url = reverse('public-holiday-detail', args=[ - public_holiday.id - ]) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) +def test_public_holiday_update(auth_client): + public_holiday = PublicHolidayFactory.create() - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + url = reverse('public-holiday-detail', args=[ + public_holiday.id + ]) - def test_public_holiday_create(self): - """Should not be able to create a new public holiday.""" - url = reverse('public-holiday-list') + response = auth_client.patch(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED +def test_public_holiday_delete(auth_client): + public_holiday = PublicHolidayFactory.create() - def test_public_holiday_update(self): - """Should not be able to update an existing public holiday.""" - public_holiday = self.public_holidays[0] + url = reverse('public-holiday-detail', args=[ + public_holiday.id + ]) - url = reverse('public-holiday-detail', args=[ - public_holiday.id - ]) + response = auth_client.delete(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED +def test_public_holiday_year_filter(auth_client): + PublicHolidayFactory.create(date=date(2017, 1, 1)) + public_holiday = PublicHolidayFactory.create(date=date(2018, 1, 1)) - def test_public_holiday_delete(self): - """Should not be able delete a public holiday.""" - public_holiday = self.public_holidays[0] + url = reverse('public-holiday-list') - url = reverse('public-holiday-detail', args=[ - public_holiday.id - ]) + response = auth_client.get(url, data={'year': 2018}) + assert response.status_code == status.HTTP_200_OK - noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_public_holiday_year_filter(self): - """Should filter the public holidays by year.""" - year = self.public_holidays[0].date.strftime('%Y') - - url = '{0}?year={1}'.format(reverse('public-holiday-list'), year) - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - result = self.result(res) - expected = PublicHoliday.objects.filter(date__year=year) - - assert len(result['data']) == len(expected) + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(public_holiday.id) diff --git a/timed/jsonapi_test_case.py b/timed/jsonapi_test_case.py index 008813931..b7efb08c0 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/jsonapi_test_case.py @@ -3,10 +3,9 @@ import json import logging -from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from rest_framework import status -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APIClient from rest_framework_jwt.settings import api_settings logging.getLogger('factory').setLevel(logging.WARN) @@ -105,26 +104,3 @@ def login(self, username, password): response.data['token'] ) ) - - -class JSONAPITestCase(APITestCase): - """Base test case for testing the timed API.""" - - def setUp(self): - """Set the clients for testing up.""" - super().setUp() - - self.user = get_user_model().objects.create_user( - username='user', - password='123qweasd', - is_staff=True, - ) - - self.client = JSONAPIClient() - self.client.login('user', '123qweasd') - - self.noauth_client = JSONAPIClient() - - def result(self, response): - """Convert the response data to JSON.""" - return json.loads(response.content.decode('utf8')) diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 36acd0018..38190e30d 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -1,92 +1,60 @@ """Tests for the customers endpoint.""" from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status -from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import CustomerFactory -class CustomerTests(JSONAPITestCase): - """Tests for the customer endpoint. +def test_customer_list_not_archived(auth_client): + CustomerFactory.create(archived=True) + customer = CustomerFactory.create(archived=False) - This endpoint should be read only for normal users. - """ + url = reverse('customer-list') - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() + response = auth_client.get(url, data={'archived': 0}) + assert response.status_code == status.HTTP_200_OK - self.customers = CustomerFactory.create_batch(10) + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(customer.id) - CustomerFactory.create_batch( - 10, - archived=True - ) - def test_customer_list(self): - """Should respond with a list of customers.""" - url = reverse('customer-list') +def test_customer_detail(auth_client): + customer = CustomerFactory.create() - noauth_res = self.noauth_client.get(url) - res = self.client.get(url, data={'archived': 0}) + url = reverse('customer-detail', args=[ + customer.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - result = self.result(res) - assert len(result['data']) == len(self.customers) +def test_customer_create(auth_client): + url = reverse('customer-list') - def test_customer_detail(self): - """Should respond with a single customer.""" - customer = self.customers[0] + response = auth_client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - url = reverse('customer-detail', args=[ - customer.id - ]) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) +def test_customer_update(auth_client): + customer = CustomerFactory.create() - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + url = reverse('customer-detail', args=[ + customer.id + ]) - def test_customer_create(self): - """Should not be able to create a new customer.""" - url = reverse('customer-list') + response = auth_client.patch(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED +def test_customer_delete(auth_client): + customer = CustomerFactory.create() - def test_customer_update(self): - """Should not be able to update an existing customer.""" - customer = self.customers[0] + url = reverse('customer-detail', args=[ + customer.id + ]) - url = reverse('customer-detail', args=[ - customer.id - ]) - - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_customer_delete(self): - """Should not be able delete a customer.""" - customer = self.customers[0] - - url = reverse('customer-detail', args=[ - customer.id - ]) - - noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.delete(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index b8011f9d9..95ee6e9f1 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -2,82 +2,24 @@ from datetime import timedelta from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status -from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import ProjectFactory, TaskFactory from timed.tracking.factories import ReportFactory -class ProjectTests(JSONAPITestCase): - """Tests for the project endpoint. +def test_project_list_not_archived(auth_client): + project = ProjectFactory.create(archived=False) + ProjectFactory.create(archived=True) - This endpoint should be read only for normal users. - """ + url = reverse('project-list') - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() + response = auth_client.get(url, data={'archived': 0}) + assert response.status_code == status.HTTP_200_OK - self.projects = ProjectFactory.create_batch(10) - - ProjectFactory.create_batch( - 10, - archived=True - ) - - def test_project_list(self): - """Should respond with a list of projects.""" - url = reverse('project-list') - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url, data={'archived': 0}) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - assert len(result['data']) == len(self.projects) - - def test_project_create(self): - """Should not be able to create a new project.""" - url = reverse('project-list') - - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_project_update(self): - """Should not be able to update an existing project.""" - project = self.projects[0] - - url = reverse('project-detail', args=[ - project.id - ]) - - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED - - def test_project_delete(self): - """Should not be able to delete a project.""" - project = self.projects[0] - - url = reverse('project-detail', args=[ - project.id - ]) - - noauth_res = self.noauth_client.delete(url) - res = self.client.patch(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(project.id) def test_project_detail_no_auth(client): @@ -88,7 +30,7 @@ def test_project_detail_no_auth(client): ]) res = client.get(url) - assert res.status_code == HTTP_401_UNAUTHORIZED + assert res.status_code == status.HTTP_401_UNAUTHORIZED def test_project_detail_no_reports(auth_client): @@ -100,7 +42,7 @@ def test_project_detail_no_reports(auth_client): res = auth_client.get(url) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK json = res.json() assert json['meta']['spent-time'] == '00:00:00' @@ -117,7 +59,36 @@ def test_project_detail_with_reports(auth_client): res = auth_client.get(url) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK json = res.json() assert json['meta']['spent-time'] == '10:00:00' + + +def test_project_create(auth_client): + url = reverse('project-list') + + response = auth_client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +def test_project_update(auth_client): + project = ProjectFactory.create() + + url = reverse('project-detail', args=[ + project.id + ]) + + response = auth_client.patch(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +def test_project_delete(auth_client): + project = ProjectFactory.create() + + url = reverse('project-detail', args=[ + project.id + ]) + + response = auth_client.delete(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 0f0baa8ee..5c595ff64 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -2,144 +2,106 @@ from datetime import date, timedelta from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_401_UNAUTHORIZED, - HTTP_405_METHOD_NOT_ALLOWED) +from rest_framework import status -from timed.jsonapi_test_case import JSONAPITestCase from timed.projects.factories import TaskFactory from timed.tracking.factories import ReportFactory -class TaskTests(JSONAPITestCase): - """Tests for the tasks endpoint. - - This endpoint should be read only for normal users. - """ - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - - self.tasks = TaskFactory.create_batch(5) - - TaskFactory.create_batch(5, archived=True) - - def test_task_list(self): - """Should respond with a list of tasks.""" - url = reverse('task-list') - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url, data={'archived': 0}) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - result = self.result(res) - - assert len(result['data']) == len(self.tasks) - - assert 'id' in result['data'][0] - assert 'name' in result['data'][0]['attributes'] - assert 'project' in result['data'][0]['relationships'] - - def test_task_my_most_frequent(self): - """Should respond with a list of my most frequent tasks.""" - report_date = date.today() - timedelta(days=20) - old_report_date = date.today() - timedelta(days=90) - - # tasks[0] should appear as most frequently used task - ReportFactory.create_batch( - 5, date=report_date, user=self.user, task=self.tasks[0] - ) - # tasks[1] should appear as secondly most frequently used task - ReportFactory.create_batch( - 4, date=report_date, user=self.user, task=self.tasks[1] - ) - # tasks[2] should not appear in result, as too far in the past - ReportFactory.create_batch( - 4, date=old_report_date, user=self.user, task=self.tasks[2] - ) - # tasks[3] should not appear in result, as project is archived - self.tasks[3].project.archived = True - self.tasks[3].project.save() - ReportFactory.create_batch( - 4, date=report_date, user=self.user, task=self.tasks[3] - ) - # tasks[4] should not appear in result, as task is archived - self.tasks[4].archived = True - self.tasks[4].save() - ReportFactory.create_batch( - 4, date=report_date, user=self.user, task=self.tasks[4] - ) - - url = reverse('task-list') - - res = self.client.get(url, {'my_most_frequent': '10'}) - assert res.status_code == HTTP_200_OK - - result = self.result(res) - data = result['data'] - assert len(data) == 2 - assert data[0]['id'] == str(self.tasks[0].id) - assert data[1]['id'] == str(self.tasks[1].id) - - def test_task_detail(self): - """Should respond with a single task.""" - task = self.tasks[0] - - url = reverse('task-detail', args=[ - task.id - ]) +def test_task_list_not_archived(auth_client): + task = TaskFactory.create(archived=False) + TaskFactory.create(archived=True) + url = reverse('task-list') + + response = auth_client.get(url, data={'archived': 0}) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(task.id) + + +def test_task_my_most_frequent(auth_client): + user = auth_client.user + tasks = TaskFactory.create_batch(6) + + report_date = date.today() - timedelta(days=20) + old_report_date = date.today() - timedelta(days=90) + + # tasks[0] should appear as most frequently used task + ReportFactory.create_batch( + 5, date=report_date, user=user, task=tasks[0] + ) + # tasks[1] should appear as secondly most frequently used task + ReportFactory.create_batch( + 4, date=report_date, user=user, task=tasks[1] + ) + # tasks[2] should not appear in result, as too far in the past + ReportFactory.create_batch( + 4, date=old_report_date, user=user, task=tasks[2] + ) + # tasks[3] should not appear in result, as project is archived + tasks[3].project.archived = True + tasks[3].project.save() + ReportFactory.create_batch( + 4, date=report_date, user=user, task=tasks[3] + ) + # tasks[4] should not appear in result, as task is archived + tasks[4].archived = True + tasks[4].save() + ReportFactory.create_batch( + 4, date=report_date, user=user, task=tasks[4] + ) + + url = reverse('task-list') + + response = auth_client.get(url, {'my_most_frequent': '10'}) + assert response.status_code == status.HTTP_200_OK + + data = response.json()['data'] + assert len(data) == 2 + assert data[0]['id'] == str(tasks[0].id) + assert data[1]['id'] == str(tasks[1].id) + + +def test_task_detail(auth_client): + task = TaskFactory.create() - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + url = reverse('task-detail', args=[ + task.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - result = self.result(res) - assert 'id' in result['data'] - assert 'name' in result['data']['attributes'] - assert 'project' in result['data']['relationships'] +def test_task_create(auth_client): + url = reverse('task-list') - def test_task_create(self): - """Should not be able to create a task.""" - url = reverse('task-list') + response = auth_client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - noauth_res = self.noauth_client.post(url) - res = self.client.post(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED +def test_task_update(auth_client): + task = TaskFactory.create() - def test_task_update(self): - """Should not be able to update an exisiting task.""" - task = self.tasks[0] + url = reverse('task-detail', args=[ + task.id + ]) - url = reverse('task-detail', args=[ - task.id - ]) + response = auth_client.patch(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - noauth_res = self.noauth_client.patch(url) - res = self.client.patch(url) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED +def test_task_delete(auth_client): + task = TaskFactory.create() - def test_task_delete(self): - """Should not be able delete a task.""" - task = self.tasks[0] + url = reverse('task-detail', args=[ + task.id + ]) - url = reverse('task-detail', args=[ - task.id - ]) - - noauth_res = self.noauth_client.delete(url) - res = self.client.delete(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_405_METHOD_NOT_ALLOWED + response = auth_client.delete(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED def test_task_detail_no_reports(auth_client): @@ -151,7 +113,7 @@ def test_task_detail_no_reports(auth_client): res = auth_client.get(url) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK json = res.json() assert json['meta']['spent-time'] == '00:00:00' @@ -167,7 +129,7 @@ def test_task_detail_with_reports(auth_client): res = auth_client.get(url) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK json = res.json() assert json['meta']['spent-time'] == '02:30:00' diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index b21b4354a..63ab3f5c0 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -1,222 +1,160 @@ -"""Tests for the activities endpoint.""" +from datetime import date, timedelta -from datetime import datetime - -from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from pytz import timezone -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) +from rest_framework import status -from timed.jsonapi_test_case import JSONAPITestCase +from timed.projects.factories import TaskFactory from timed.tracking.factories import ActivityBlockFactory, ActivityFactory -class ActivityTests(JSONAPITestCase): - """Tests for the activities endpoint.""" - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - - other_user = get_user_model().objects.create_user( - username='test', - password='123qweasd' - ) - - self.activities = ActivityFactory.create_batch( - 10, - user=self.user - ) - - for activity in self.activities: - ActivityBlockFactory.create_batch(5, activity=activity) - - ActivityFactory.create_batch( - 10, - user=other_user - ) +def test_activity_list(auth_client): + activity = ActivityFactory.create(user=auth_client.user) + url = reverse('activity-list') - def test_activity_list(self): - """Should respond with a list of activities filtered by user.""" - url = reverse('activity-list') + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(activity.id) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - result = self.result(res) +def test_activity_detail(auth_client): + activity = ActivityFactory.create(user=auth_client.user) - assert len(result['data']) == len(self.activities) + url = reverse('activity-detail', args=[ + activity.id + ]) - def test_activity_detail(self): - """Should respond with a single activity.""" - activity = self.activities[0] + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - url = reverse('activity-detail', args=[ - activity.id - ]) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) +def test_activity_create(auth_client): + """Should create a new activity and automatically set the user.""" + user = auth_client.user + task = TaskFactory.create() - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - def test_activity_create(self): - """Should create a new activity and automatically set the user.""" - task = self.activities[0].task - - data = { - 'data': { - 'type': 'activities', - 'id': None, - 'attributes': { - 'date': '2017-01-01', - 'comment': 'Test activity' - }, - 'relationships': { - 'task': { - 'data': { - 'type': 'tasks', - 'id': task.id - } + data = { + 'data': { + 'type': 'activities', + 'id': None, + 'attributes': { + 'date': '2017-01-01', + 'comment': 'Test activity' + }, + 'relationships': { + 'task': { + 'data': { + 'type': 'tasks', + 'id': task.id } } } } + } - url = reverse('activity-list') - - noauth_res = self.noauth_client.post(url, data) - res = self.client.post(url, data) + url = reverse('activity-list') - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_201_CREATED + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED - result = self.result(res) + json = response.json() + assert ( + int(json['data']['relationships']['user']['data']['id']) == + int(user.id) + ) - assert ( - int(result['data']['relationships']['user']['data']['id']) == - int(self.user.id) - ) - def test_activity_update(self): - """Should update an existing activity.""" - activity = self.activities[0] +def test_activity_update(auth_client): + activity = ActivityFactory.create(user=auth_client.user) - data = { - 'data': { - 'type': 'activities', - 'id': activity.id, - 'attributes': { - 'comment': 'Test activity 2' - } + data = { + 'data': { + 'type': 'activities', + 'id': activity.id, + 'attributes': { + 'comment': 'Test activity 2' } } + } - url = reverse('activity-detail', args=[ - activity.id - ]) - - noauth_res = self.noauth_client.patch(url, data) - res = self.client.patch(url, data) + url = reverse('activity-detail', args=[ + activity.id + ]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK - result = self.result(res) + json = response.json() + assert ( + json['data']['attributes']['comment'] == + data['data']['attributes']['comment'] + ) - assert ( - result['data']['attributes']['comment'] == - data['data']['attributes']['comment'] - ) - def test_activity_delete(self): - """Should delete an activity.""" - activity = self.activities[0] +def test_activity_delete(auth_client): + activity = ActivityFactory.create(user=auth_client.user) - url = reverse('activity-detail', args=[ - activity.id - ]) + url = reverse('activity-detail', args=[ + activity.id + ]) - noauth_res = self.noauth_client.delete(url) - res = self.client.delete(url) + response = auth_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_204_NO_CONTENT - def test_activity_list_filter_active(self): - """Should respond with a list of active activities.""" - activity = self.activities[0] - block = ActivityBlockFactory.create(activity=activity) +def test_activity_list_filter_active(auth_client): + user = auth_client.user + ActivityFactory.create(user=user) + activity = ActivityFactory.create(user=user) + ActivityBlockFactory.create(activity=activity, to_time=None) - block.to_time = None - block.save() + url = reverse('activity-list') - url = reverse('activity-list') + response = auth_client.get(url, data={'active': 'true'}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(activity.id) - res = self.client.get('{0}?active=true'.format(url)) - result = self.result(res) +def test_activity_list_filter_day(auth_client): + user = auth_client.user + day = date(2016, 2, 2) + ActivityFactory.create(date=day - timedelta(days=1), user=user) + activity = ActivityFactory.create(date=day, user=user) - assert len(result['data']) == 1 + url = reverse('activity-list') + response = auth_client.get(url, data={'day': day.strftime('%Y-%m-%d')}) + assert response.status_code == status.HTTP_200_OK - assert int(result['data'][0]['id']) == int(activity.id) + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(activity.id) - def test_activity_list_filter_day(self): - """Should respond with a list of activities starting today.""" - now = datetime.now(timezone('Europe/Zurich')) - activity = self.activities[0] - activity.date = now - activity.save() - - url = reverse('activity-list') - - res = self.client.get('{0}?day={1}'.format( - url, - now.strftime('%Y-%m-%d') - )) - - result = self.result(res) - - assert len(result['data']) >= 1 - - assert any([ - int(data['id']) == activity.id - for data - in result['data'] - ]) - - def test_activity_create_no_task(self): - """Should create a new activity without a task.""" - data = { - 'data': { - 'type': 'activities', - 'id': None, - 'attributes': { - 'date': '2017-01-01', - 'comment': 'Test activity' - }, - 'relationships': { - 'task': { - 'data': None - } +def test_activity_create_no_task(auth_client): + """Should create a new activity without a task.""" + data = { + 'data': { + 'type': 'activities', + 'id': None, + 'attributes': { + 'date': '2017-01-01', + 'comment': 'Test activity' + }, + 'relationships': { + 'task': { + 'data': None } } } + } - url = reverse('activity-list') - - noauth_res = self.noauth_client.post(url, data) - res = self.client.post(url, data) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_201_CREATED - - result = self.result(res) + url = reverse('activity-list') + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED - assert result['data']['relationships']['task']['data'] is None + json = response.json() + assert json['data']['relationships']['task']['data'] is None diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py index c515da3b5..f2c5e0bd4 100644 --- a/timed/tracking/tests/test_activity_block.py +++ b/timed/tracking/tests/test_activity_block.py @@ -2,150 +2,106 @@ from datetime import time -from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, - HTTP_401_UNAUTHORIZED) +from rest_framework import status -from timed.jsonapi_test_case import JSONAPITestCase from timed.tracking.factories import ActivityBlockFactory, ActivityFactory -class ActivityBlockTests(JSONAPITestCase): - """Tests for the activity blocks endpoint.""" +def test_activity_block_list(auth_client): + user = auth_client.user + activity = ActivityFactory.create(user=user) + block = ActivityBlockFactory.create(activity=activity) + ActivityBlockFactory.create() - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - - other_user = get_user_model().objects.create_user( - username='test', - password='123qweasd' - ) - - activity = ActivityFactory.create(user=self.user) - other_activity = ActivityFactory.create(user=other_user) - - self.activity_blocks = ActivityBlockFactory.create_batch( - 10, - activity=activity - ) - - ActivityBlockFactory.create_batch( - 10, - activity=other_activity - ) - - def test_activity_block_list(self): - """Should respond with a list of activity blocks.""" - url = reverse('activity-block-list') - - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + url = reverse('activity-block-list') + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - result = self.result(res) + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(block.id) - assert len(result['data']) == len(self.activity_blocks) - def test_activity_block_detail(self): - """Should respond with a single activity block.""" - activity_block = self.activity_blocks[0] +def test_activity_block_detail(auth_client): + user = auth_client.user + activity = ActivityFactory.create(user=user) + block = ActivityBlockFactory.create(activity=activity) - url = reverse('activity-block-detail', args=[ - activity_block.id - ]) + url = reverse('activity-block-detail', args=[block.id]) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - def test_activity_block_create(self): - """Should create a new activity block.""" - activity = self.activity_blocks[0].activity +def test_activity_block_create(auth_client): + user = auth_client.user + activity = ActivityFactory.create(user=user) - data = { - 'data': { - 'type': 'activity-blocks', - 'id': None, - 'attributes': { - 'from-time': '08:00' - }, - 'relationships': { - 'activity': { - 'data': { - 'type': 'activities', - 'id': activity.id - } + data = { + 'data': { + 'type': 'activity-blocks', + 'id': None, + 'attributes': { + 'from-time': '08:00' + }, + 'relationships': { + 'activity': { + 'data': { + 'type': 'activities', + 'id': activity.id } } } } + } - url = reverse('activity-block-list') - - noauth_res = self.noauth_client.post(url, data) - res = self.client.post(url, data) + url = reverse('activity-block-list') - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_201_CREATED + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED - result = self.result(res) + json = response.json() + assert not json['data']['attributes']['from-time'] == '08:00' + assert json['data']['attributes']['to-time'] is None - assert not result['data']['attributes']['from-time'] == '08:00' - assert result['data']['attributes']['to-time'] is None - def test_activity_block_update(self): - """Should update an existing activity block.""" - activity_block = self.activity_blocks[0] - activity_block.from_time = time(10, 0) - activity_block.save() +def test_activity_block_update(auth_client): + user = auth_client.user + activity = ActivityFactory.create(user=user) + block = ActivityBlockFactory.create(activity=activity, to_time=time(10, 0)) - data = { - 'data': { - 'type': 'activity-blocks', - 'id': activity_block.id, - 'attributes': { - 'to-time': '23:59:00' - } + data = { + 'data': { + 'type': 'activity-blocks', + 'id': block.id, + 'attributes': { + 'to-time': '23:59:00', + 'from-time': '10:00:00' } } + } - url = reverse('activity-block-detail', args=[ - activity_block.id - ]) - - noauth_res = self.noauth_client.patch(url, data) - res = self.client.patch(url, data) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - result = self.result(res) + url = reverse('activity-block-detail', args=[block.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK - assert ( - result['data']['attributes']['to-time'] == - data['data']['attributes']['to-time'] - ) + json = response.json() + assert ( + json['data']['attributes']['to-time'] == + data['data']['attributes']['to-time'] + ) - def test_activity_block_delete(self): - """Should delete an activity block.""" - activity_block = self.activity_blocks[0] - url = reverse('activity-block-detail', args=[ - activity_block.id - ]) +def test_activity_block_delete(auth_client): + user = auth_client.user + activity = ActivityFactory.create(user=user) + block = ActivityBlockFactory.create(activity=activity) - noauth_res = self.noauth_client.delete(url) - res = self.client.delete(url) + url = reverse('activity-block-detail', args=[block.id]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_204_NO_CONTENT + response = auth_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT def test_activity_block_active_unique(auth_client): @@ -175,7 +131,7 @@ def test_activity_block_active_unique(auth_client): res = auth_client.post(url, data) - assert res.status_code == HTTP_400_BAD_REQUEST + assert res.status_code == status.HTTP_400_BAD_REQUEST json = res.json() assert json['errors'][0]['detail'] == ( 'A user can only have one active activity' @@ -205,7 +161,7 @@ def test_activity_block_to_before_from(auth_client): res = auth_client.patch(url, data) - assert res.status_code == HTTP_400_BAD_REQUEST + assert res.status_code == status.HTTP_400_BAD_REQUEST json = res.json() assert json['errors'][0]['detail'] == ( 'An activity block may not end before it starts.' diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index 2a807a70a..f3e6ca5c8 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -1,136 +1,85 @@ -"""Tests for the attendances endpoint.""" - -from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED) +from rest_framework import status -from timed.jsonapi_test_case import JSONAPITestCase from timed.tracking.factories import AttendanceFactory -class AttendanceTests(JSONAPITestCase): - """Tests for the attendances endpoint.""" - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() - - other_user = get_user_model().objects.create_user( - username='test', - password='123qweasd' - ) - - self.attendances = AttendanceFactory.create_batch( - 10, - user=self.user - ) - - AttendanceFactory.create_batch( - 10, - user=other_user - ) - - def test_attendance_list(self): - """Should respond with a list of attendances filtered by user.""" - url = reverse('attendance-list') +def test_attendance_list(auth_client): + AttendanceFactory.create() + attendance = AttendanceFactory.create(user=auth_client.user) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) + url = reverse('attendance-list') + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(attendance.id) - result = self.result(res) - assert len(result['data']) == len(self.attendances) +def test_attendance_detail(auth_client): + attendance = AttendanceFactory.create(user=auth_client.user) - def test_attendance_detail(self): - """Should respond with a single attendance.""" - attendance = self.attendances[0] + url = reverse('attendance-detail', args=[attendance.id]) + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK - url = reverse('attendance-detail', args=[ - attendance.id - ]) - noauth_res = self.noauth_client.get(url) - res = self.client.get(url) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK - - def test_attendance_create(self): - """Should create a new attendance and automatically set the user.""" - data = { - 'data': { - 'type': 'attendances', - 'id': None, - 'attributes': { - 'date': '2017-01-01', - 'from-time': '08:00', - 'to-time': '10:00' - } +def test_attendance_create(auth_client): + """Should create a new attendance and automatically set the user.""" + user = auth_client.user + data = { + 'data': { + 'type': 'attendances', + 'id': None, + 'attributes': { + 'date': '2017-01-01', + 'from-time': '08:00', + 'to-time': '10:00' } } + } - url = reverse('attendance-list') - - noauth_res = self.noauth_client.post(url, data) - res = self.client.post(url, data) + url = reverse('attendance-list') - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_201_CREATED + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED - result = self.result(res) + json = response.json() + assert ( + json['data']['relationships']['user']['data']['id'] == str(user.id) + ) - assert not result['data']['id'] is None - assert ( - int(result['data']['relationships']['user']['data']['id']) == - int(self.user.id) - ) +def test_attendance_update(auth_client): + attendance = AttendanceFactory.create(user=auth_client.user) - def test_attendance_update(self): - """Should update and existing attendance.""" - attendance = self.attendances[0] - - data = { - 'data': { - 'type': 'attendances', - 'id': attendance.id, - 'attributes': { - 'to-time': '15:00:00' - } + data = { + 'data': { + 'type': 'attendances', + 'id': attendance.id, + 'attributes': { + 'to-time': '15:00:00' } } + } - url = reverse('attendance-detail', args=[ - attendance.id - ]) - - noauth_res = self.noauth_client.patch(url, data) - res = self.client.patch(url, data) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_200_OK + url = reverse('attendance-detail', args=[attendance.id]) - result = self.result(res) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK - assert ( - result['data']['attributes']['to-time'] == - data['data']['attributes']['to-time'] - ) + json = response.json() + assert ( + json['data']['attributes']['to-time'] == + data['data']['attributes']['to-time'] + ) - def test_attendance_delete(self): - """Should delete an attendance.""" - attendance = self.attendances[0] - url = reverse('attendance-detail', args=[ - attendance.id - ]) +def test_attendance_delete(auth_client): + attendance = AttendanceFactory.create(user=auth_client.user) - noauth_res = self.noauth_client.delete(url) - res = self.client.delete(url) + url = reverse('attendance-detail', args=[attendance.id]) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert res.status_code == HTTP_204_NO_CONTENT + response = auth_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 0269d0924..14200fac7 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -11,444 +11,404 @@ from hypothesis.extra.django.models import models from hypothesis.strategies import (builds, characters, dates, lists, sampled_from, timedeltas) -from rest_framework.status import (HTTP_200_OK, HTTP_201_CREATED, - HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, - HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN) +from rest_framework import status from timed.employment.factories import UserFactory -from timed.jsonapi_test_case import JSONAPIClient, JSONAPITestCase +from timed.jsonapi_test_case import JSONAPIClient from timed.projects.factories import (CostCenterFactory, ProjectFactory, TaskFactory) from timed.tracking.factories import ReportFactory from timed.tracking.models import Report -class ReportTests(JSONAPITestCase): - """Tests for the reports endpoint.""" - - def setUp(self): - """Set the environment for the tests up.""" - super().setUp() +def test_report_list(auth_client): + user = auth_client.user + ReportFactory.create(user=user) + report = ReportFactory.create(user=user, duration=timedelta(hours=1)) + url = reverse('report-list') - other_user = get_user_model().objects.create_user( - username='test', - password='123qweasd' + response = auth_client.get(url, data={ + 'date': report.date, + 'user': user.id, + 'task': report.task_id, + 'project': report.task.project_id, + 'customer': report.task.project.customer_id, + 'include': ( + 'user,task,task.project,task.project.customer,verified_by' ) + }) - self.reports = ReportFactory.create_batch(10, user=self.user, - duration=timedelta(hours=1)) - self.other_reports = ReportFactory.create_batch(10, user=other_user) - - def test_report_list(self): - """Should respond with a list of filtered reports.""" - url = reverse('report-list') - - noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url, data={ - 'date': self.reports[0].date, - 'user': self.user.id, - 'task': self.reports[0].task.id, - 'project': self.reports[0].task.project.id, - 'customer': self.reports[0].task.project.customer.id, - 'include': ( - 'user,task,task.project,task.project.customer,verified_by' - ) - }) - - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK - - result = self.result(user_res) - - assert len(result['data']) == 1 - assert result['data'][0]['id'] == str(self.reports[0].id) - assert result['meta']['total-time'] == '01:00:00' - - def test_report_list_filter_reviewer(self): - report = self.reports[0] - report.task.project.reviewers.add(self.user) - - url = reverse('report-list') - - res = self.client.get(url, data={'reviewer': self.user.id}) - assert res.status_code == HTTP_200_OK - result = self.result(res) - assert len(result['data']) == 1 - assert result['data'][0]['id'] == str(report.id) - - def test_report_list_verify(self): - url_list = reverse('report-list') - res = self.client.get(url_list, data={'not_verified': 1}) - assert res.status_code == HTTP_200_OK - result = self.result(res) - assert len(result['data']) == 20 - - url_verify = reverse('report-verify') - res = self.client.post(url_verify, QUERY_STRING='user=%s' % - self.user.id) - assert res.status_code == HTTP_200_OK - - res = self.client.get(url_list, data={'not_verified': 0}) - assert res.status_code == HTTP_200_OK - result = self.result(res) - assert len(result['data']) == 10 - assert result['meta']['total-time'] == '10:00:00' - - def test_report_list_verify_non_admin(self): - """Non admin resp. non staff user may not verify reports.""" - self.user.is_staff = False - self.user.save() - - url_verify = reverse('report-verify') - res = self.client.post(url_verify, QUERY_STRING='user=%s' % - self.user.id) - assert res.status_code == HTTP_403_FORBIDDEN - - def test_report_list_verify_page(self): - url_verify = reverse('report-verify') - res = self.client.post(url_verify, QUERY_STRING='user=%s&page_size=5' % - self.user.id) - assert res.status_code == HTTP_200_OK - - url_list = reverse('report-list') - res = self.client.get(url_list, data={'not_verified': 0}) - assert res.status_code == HTTP_200_OK - result = self.result(res) - assert len(result['data']) == 5 - - def test_report_export_missing_type(self): - url = reverse('report-export') + assert response.status_code == status.HTTP_200_OK - user_res = self.client.get(url, data={ - 'user': self.user.id, - }) + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(report.id) + assert json['meta']['total-time'] == '01:00:00' - assert user_res.status_code == HTTP_400_BAD_REQUEST - def test_report_detail(self): - """Should respond with a single report.""" - report = self.reports[0] +def test_report_list_filter_reviewer(auth_client): + user = auth_client.user + report = ReportFactory.create(user=user) + report.task.project.reviewers.add(user) - url = reverse('report-detail', args=[ - report.id - ]) + url = reverse('report-list') - noauth_res = self.noauth_client.get(url) - user_res = self.client.get(url) + response = auth_client.get(url, data={'reviewer': user.id}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(report.id) - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK - def test_report_create(self): - """Should create a new report and automatically set the user.""" - task = TaskFactory.create() +def test_report_list_verify(admin_client): + user = admin_client.user + report = ReportFactory.create(user=user, duration=timedelta(hours=1)) + ReportFactory.create() - data = { - 'data': { - 'type': 'reports', - 'id': None, - 'attributes': { - 'comment': 'foo', - 'duration': '00:50:00', - 'date': '2017-02-01' - }, - 'relationships': { - 'task': { - 'data': { - 'type': 'tasks', - 'id': task.id - } - }, - 'verified-by': { - 'data': None - }, - } - } - } + url_list = reverse('report-list') + response = admin_client.get(url_list, data={'not_verified': 1}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 2 - url = reverse('report-list') + url_verify = reverse('report-verify') + response = admin_client.post(url_verify, QUERY_STRING='user=%s' % user.id) + assert response.status_code == status.HTTP_200_OK - noauth_res = self.noauth_client.post(url, data) - user_res = self.client.post(url, data) + response = admin_client.get(url_list, data={'not_verified': 0}) + assert response.status_code == status.HTTP_200_OK - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_201_CREATED + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(report.id) + assert json['meta']['total-time'] == '01:00:00' - result = self.result(user_res) - assert ( - int(result['data']['relationships']['user']['data']['id']) == - int(self.user.id) - ) +def test_report_list_verify_non_admin(auth_client): + """Non admin resp. non staff user may not verify reports.""" + user = auth_client.user + url_verify = reverse('report-verify') - assert ( - int(result['data']['relationships']['task']['data']['id']) == - int(data['data']['relationships']['task']['data']['id']) - ) + res = auth_client.post(url_verify, QUERY_STRING='user=%s' % user.id) + assert res.status_code == status.HTTP_403_FORBIDDEN - def test_report_update_verified_as_non_staff_but_owner(self): - """Test that an owner (not staff) may not change a verified report.""" - report = self.reports[0] - report.verified_by = self.user - report.duration = timedelta(hours=2) - report.save() - - url = reverse('report-detail', args=[ - report.id - ]) - - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'duration': '01:00:00', - }, - } - } - client = JSONAPIClient() - client.login('test', '123qweasd') - res = client.patch(url, data) - assert res.status_code == HTTP_403_FORBIDDEN - - def test_report_update_owner(self): - """Should update an existing report.""" - report = self.reports[0] - task = TaskFactory.create() - - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', - 'duration': '01:00:00', - 'date': '2017-02-04' - }, - 'relationships': { - 'task': { - 'data': { - 'type': 'tasks', - 'id': task.id - } - } - } - } - } +def test_report_list_verify_page(admin_client): + user = admin_client.user + ReportFactory.create_batch(2, user=user) - url = reverse('report-detail', args=[ - report.id - ]) + url_verify = reverse('report-verify') + response = admin_client.post( + url_verify, + QUERY_STRING='user=%s&page_size=1' % user.id + ) + assert response.status_code == status.HTTP_200_OK - noauth_res = self.noauth_client.patch(url, data) - user_res = self.client.patch(url, data) + url_list = reverse('report-list') + response = admin_client.get(url_list, data={'not_verified': 0}) + assert response.status_code == status.HTTP_200_OK - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 - result = self.result(user_res) - assert ( - result['data']['attributes']['comment'] == - data['data']['attributes']['comment'] - ) +def test_report_export_missing_type(auth_client): + user = auth_client.user + url = reverse('report-export') - assert ( - result['data']['attributes']['duration'] == - data['data']['attributes']['duration'] - ) + response = auth_client.get(url, data={'user': user.id}) - assert ( - result['data']['attributes']['date'] == - data['data']['attributes']['date'] - ) + assert response.status_code == status.HTTP_400_BAD_REQUEST - assert ( - int(result['data']['relationships']['task']['data']['id']) == - int(data['data']['relationships']['task']['data']['id']) - ) - def test_report_update_date_staff(self): - report = self.other_reports[0] +def test_report_detail(auth_client): + user = auth_client.user + report = ReportFactory.create(user=user) - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'date': '2017-02-04' + url = reverse('report-detail', args=[report.id]) + response = auth_client.get(url) + + assert response.status_code == status.HTTP_200_OK + + +def test_report_create(auth_client): + """Should create a new report and automatically set the user.""" + user = auth_client.user + task = TaskFactory.create() + + data = { + 'data': { + 'type': 'reports', + 'id': None, + 'attributes': { + 'comment': 'foo', + 'duration': '00:50:00', + 'date': '2017-02-01' + }, + 'relationships': { + 'task': { + 'data': { + 'type': 'tasks', + 'id': task.id + } + }, + 'verified-by': { + 'data': None }, } } + } - url = reverse('report-detail', args=[ - report.id - ]) + url = reverse('report-list') - res = self.client.patch(url, data) - assert res.status_code == HTTP_400_BAD_REQUEST + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED - def test_report_update_duration_staff(self): - report = self.other_reports[0] - report.duration = timedelta(hours=2) - report.save() + json = response.json() + assert ( + json['data']['relationships']['user']['data']['id'] == str(user.id) + ) - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'duration': '01:00:00', - }, + assert json['data']['relationships']['task']['data']['id'] == str(task.id) + + +def test_report_update_verified_as_non_staff_but_owner(auth_client): + """Test that an owner (not staff) may not change a verified report.""" + user = auth_client.user + report = ReportFactory.create( + user=user, verified_by=user, duration=timedelta(hours=2) + ) + + url = reverse('report-detail', args=[report.id]) + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'duration': '01:00:00', + }, + } + } + + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_report_update_owner(auth_client): + """Should update an existing report.""" + user = auth_client.user + report = ReportFactory.create(user=user) + task = TaskFactory.create() + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + 'duration': '01:00:00', + 'date': '2017-02-04' + }, + 'relationships': { + 'task': { + 'data': { + 'type': 'tasks', + 'id': task.id + } + } } } + } - url = reverse('report-detail', args=[ - report.id - ]) - - res = self.client.patch(url, data) - assert res.status_code == HTTP_400_BAD_REQUEST - - def test_report_update_not_staff_user(self): - """Updating of report belonging to different user is not allowed.""" - report = self.reports[0] - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', - }, - } + url = reverse('report-detail', args=[ + report.id + ]) + + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert ( + json['data']['attributes']['comment'] == + data['data']['attributes']['comment'] + ) + assert ( + json['data']['attributes']['duration'] == + data['data']['attributes']['duration'] + ) + assert ( + json['data']['attributes']['date'] == + data['data']['attributes']['date'] + ) + assert ( + json['data']['relationships']['task']['data']['id'] == + str(data['data']['relationships']['task']['data']['id']) + ) + + +def test_report_update_date_staff(admin_client): + report = ReportFactory.create() + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'date': '2017-02-04' + }, } + } - url = reverse('report-detail', args=[ - report.id - ]) + url = reverse('report-detail', args=[report.id]) - client = JSONAPIClient() - client.login('test', '123qweasd') - res = client.patch(url, data) - assert res.status_code == HTTP_403_FORBIDDEN - - def test_report_set_verified_by_not_staff_user(self): - """Not staff user may not set verified by.""" - self.user.is_staff = False - self.user.save() - - report = self.reports[0] - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'relationships': { - 'verified-by': { - 'data': { - 'id': self.user.id, - 'type': 'users' - } - }, - } - } + response = admin_client.patch(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_report_update_duration_staff(admin_client): + report = ReportFactory.create(duration=timedelta(hours=2)) + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'duration': '01:00:00', + }, } + } + + url = reverse('report-detail', args=[ + report.id + ]) - url = reverse('report-detail', args=[ - report.id - ]) + res = admin_client.patch(url, data) + assert res.status_code == status.HTTP_400_BAD_REQUEST - res = self.client.patch(url, data) - assert res.status_code == HTTP_400_BAD_REQUEST - def test_report_update_staff_user(self): - report = self.reports[0] +def test_report_update_not_staff_user(auth_client): + """Updating of report belonging to different user is not allowed.""" + report = ReportFactory.create() + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + } + } - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', + url = reverse('report-detail', args=[report.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_report_set_verified_by_not_staff_user(auth_client): + """Not staff user may not set verified by.""" + user = auth_client.user + report = ReportFactory.create(user=user) + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'relationships': { + 'verified-by': { + 'data': { + 'id': user.id, + 'type': 'users' + } }, - 'relationships': { - 'verified-by': { - 'data': { - 'id': self.user.id, - 'type': 'users' - } - }, - } } } + } - url = reverse('report-detail', args=[ - report.id - ]) - - res = self.client.patch(url, data) - assert res.status_code == HTTP_200_OK - - def test_report_reset_verified_by_staff_user(self): - """Staff user may reset verified by on report.""" - report = self.reports[0] - report.verified_by = self.user - report.save() - - data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', + url = reverse('report-detail', args=[report.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_report_update_staff_user(admin_client): + user = admin_client.user + report = ReportFactory.create(user=user) + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + 'relationships': { + 'verified-by': { + 'data': { + 'id': user.id, + 'type': 'users' + } }, - 'relationships': { - 'verified-by': { - 'data': None - }, - } } } + } - url = reverse('report-detail', args=[ - report.id - ]) + url = reverse('report-detail', args=[report.id]) - res = self.client.patch(url, data) - assert res.status_code == HTTP_200_OK + response = admin_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + +def test_report_reset_verified_by_staff_user(admin_client): + """Staff user may reset verified by on report.""" + user = admin_client.user + report = ReportFactory.create(user=user, verified_by=user) + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + 'relationships': { + 'verified-by': { + 'data': None + }, + } + } + } + + url = reverse('report-detail', args=[report.id]) + response = admin_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK - def test_report_delete(self): - """Should delete a report.""" - report = self.reports[0] - url = reverse('report-detail', args=[ - report.id - ]) +def test_report_delete(auth_client): + user = auth_client.user + report = ReportFactory.create(user=user) - noauth_res = self.noauth_client.delete(url) - user_res = self.client.delete(url) + url = reverse('report-detail', args=[report.id]) + response = auth_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT - assert noauth_res.status_code == HTTP_401_UNAUTHORIZED - assert user_res.status_code == HTTP_204_NO_CONTENT - def test_report_round_duration(self): - """Should round the duration of a report to 15 minutes.""" - report = self.reports[0] +def test_report_round_duration(db): + """Should round the duration of a report to 15 minutes.""" + report = ReportFactory.create() - report.duration = timedelta(hours=1, minutes=7) - report.save() + report.duration = timedelta(hours=1, minutes=7) + report.save() - assert duration_string(report.duration) == '01:00:00' + assert duration_string(report.duration) == '01:00:00' - report.duration = timedelta(hours=1, minutes=8) - report.save() + report.duration = timedelta(hours=1, minutes=8) + report.save() - assert duration_string(report.duration) == '01:15:00' + assert duration_string(report.duration) == '01:15:00' - report.duration = timedelta(hours=1, minutes=53) - report.save() + report.duration = timedelta(hours=1, minutes=53) + report.save() - assert duration_string(report.duration) == '02:00:00' + assert duration_string(report.duration) == '02:00:00' class TestReportHypo(TestCase): @@ -486,7 +446,7 @@ def test_report_export(self, file_type, reports): 'file_type': file_type }) - assert user_res.status_code == HTTP_200_OK + assert user_res.status_code == status.HTTP_200_OK book = pyexcel.get_book( file_content=user_res.content, file_type=file_type ) @@ -499,7 +459,7 @@ def test_report_list_no_result(admin_client): url = reverse('report-list') res = admin_client.get(url) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK json = res.json() assert json['meta']['total-time'] == '00:00:00' @@ -510,7 +470,7 @@ def test_report_delete_superuser(superadmin_client): url = reverse('report-detail', args=[report.id]) response = superadmin_client.delete(url) - assert response.status_code == HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_403_FORBIDDEN def test_report_list_filter_cost_center(auth_client): @@ -531,7 +491,7 @@ def test_report_list_filter_cost_center(auth_client): url = reverse('report-list') res = auth_client.get(url, data={'cost_center': cost_center.id}) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK json = res.json() assert len(json['data']) == 2 ids = {int(entry['id']) for entry in json['data']} From c4ea39f9ac1c761303479265bd935cca6acb39b1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 Nov 2017 17:08:27 +0100 Subject: [PATCH 306/980] Move JSONAPIClient into tests.client module --- timed/conftest.py | 2 +- .../{jsonapi_test_case.py => tests/client.py} | 10 +++----- timed/tests/test_client.py | 23 +++++++++++++++++++ timed/tracking/tests/test_report.py | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) rename timed/{jsonapi_test_case.py => tests/client.py} (91%) create mode 100644 timed/tests/test_client.py diff --git a/timed/conftest.py b/timed/conftest.py index 7b1acfab8..894a05366 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -1,7 +1,7 @@ import pytest from django.contrib.auth import get_user_model -from timed.jsonapi_test_case import JSONAPIClient +from timed.tests.client import JSONAPIClient @pytest.fixture diff --git a/timed/jsonapi_test_case.py b/timed/tests/client.py similarity index 91% rename from timed/jsonapi_test_case.py rename to timed/tests/client.py index b7efb08c0..94180965f 100644 --- a/timed/jsonapi_test_case.py +++ b/timed/tests/client.py @@ -1,16 +1,12 @@ """Helpers for testing with JSONAPI.""" import json -import logging from django.core.urlresolvers import reverse -from rest_framework import status +from rest_framework import exceptions, status from rest_framework.test import APIClient from rest_framework_jwt.settings import api_settings -logging.getLogger('factory').setLevel(logging.WARN) -logging.getLogger('django_auth_ldap').setLevel(logging.WARN) - class JSONAPIClient(APIClient): """Base API client for testing CRUD methods with JSONAPI format.""" @@ -81,7 +77,7 @@ def login(self, username, password): :param str username: Username of the user :param str password: Password of the user - :raises: Exception + :raises: exceptions.AuthenticationFailed """ data = { 'data': { @@ -96,7 +92,7 @@ def login(self, username, password): response = self.post(reverse('login'), data) if response.status_code != status.HTTP_200_OK: - raise Exception('Wrong credentials!') # pragma: no cover + raise exceptions.AuthenticationFailed() self.credentials( HTTP_AUTHORIZATION='{0} {1}'.format( diff --git a/timed/tests/test_client.py b/timed/tests/test_client.py new file mode 100644 index 000000000..7cbd7a823 --- /dev/null +++ b/timed/tests/test_client.py @@ -0,0 +1,23 @@ +import pytest +from django.contrib.auth import get_user_model +from rest_framework import exceptions + +from timed.tests.client import JSONAPIClient + + +def test_client_login(db): + get_user_model().objects.create_user( + username='user', + password='123qweasd', + first_name='Test', + last_name='User', + ) + + client = JSONAPIClient() + client.login('user', '123qweasd') + + +def test_client_login_fails(db): + client = JSONAPIClient() + with pytest.raises(exceptions.AuthenticationFailed): + client.login('someuser', 'invalidpw') diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 14200fac7..ee3886049 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -14,9 +14,9 @@ from rest_framework import status from timed.employment.factories import UserFactory -from timed.jsonapi_test_case import JSONAPIClient from timed.projects.factories import (CostCenterFactory, ProjectFactory, TaskFactory) +from timed.tests.client import JSONAPIClient from timed.tracking.factories import ReportFactory from timed.tracking.models import Report From 929289c8ee887c8dc525cb961ab71a91270b0c85 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 Nov 2017 17:24:11 +0100 Subject: [PATCH 307/980] Replace hypothesis test with pytest parametrized hypothesis doesn't work well with pytest fixtures which causes database not to be cleaned up after one sample. --- dev_requirements.txt | 1 - timed/tracking/tests/test_report.py | 74 ++++++++--------------------- 2 files changed, 20 insertions(+), 55 deletions(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 0a902a126..e9bcfaa53 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -8,7 +8,6 @@ flake8-docstrings flake8-isort flake8-quotes flake8-string-format -hypothesis==3.13.1 ipdb isort pytest diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index ee3886049..9926781f6 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1,24 +1,16 @@ """Tests for the reports endpoint.""" -from datetime import date, timedelta +from datetime import timedelta import pyexcel -from django.contrib.auth import get_user_model +import pytest from django.core.urlresolvers import reverse from django.utils.duration import duration_string -from hypothesis import HealthCheck, given, settings -from hypothesis.extra.django import TestCase -from hypothesis.extra.django.models import models -from hypothesis.strategies import (builds, characters, dates, lists, - sampled_from, timedeltas) from rest_framework import status -from timed.employment.factories import UserFactory from timed.projects.factories import (CostCenterFactory, ProjectFactory, TaskFactory) -from timed.tests.client import JSONAPIClient from timed.tracking.factories import ReportFactory -from timed.tracking.models import Report def test_report_list(auth_client): @@ -411,50 +403,6 @@ def test_report_round_duration(db): assert duration_string(report.duration) == '02:00:00' -class TestReportHypo(TestCase): - @given( - sampled_from(['csv', 'xlsx', 'ods']), - lists( - models( - Report, - comment=characters(blacklist_categories=['Cc', 'Cs']), - task=builds(TaskFactory.create), - user=builds(UserFactory.create), - date=dates( - min_date=date(2000, 1, 1), - max_date=date(2100, 1, 1), - ), - duration=timedeltas( - min_delta=timedelta(0), - max_delta=timedelta(days=1) - ) - ), - min_size=1, - max_size=5, - ) - ) - @settings(timeout=5, suppress_health_check=[HealthCheck.too_slow]) - def test_report_export(self, file_type, reports): - get_user_model().objects.create_user(username='test', - password='1234qwer') - client = JSONAPIClient() - client.login('test', '1234qwer') - url = reverse('report-export') - - with self.assertNumQueries(2): - user_res = client.get(url, data={ - 'file_type': file_type - }) - - assert user_res.status_code == status.HTTP_200_OK - book = pyexcel.get_book( - file_content=user_res.content, file_type=file_type - ) - # bookdict is a dict of tuples(name, content) - sheet = book.bookdict.popitem()[1] - assert len(sheet) == len(reports) + 1 - - def test_report_list_no_result(admin_client): url = reverse('report-list') res = admin_client.get(url) @@ -496,3 +444,21 @@ def test_report_list_filter_cost_center(auth_client): assert len(json['data']) == 2 ids = {int(entry['id']) for entry in json['data']} assert {report_task.id, report_project.id} == ids + + +@pytest.mark.parametrize('file_type', ['csv', 'xlsx', 'ods']) +def test_report_export(auth_client, file_type, django_assert_num_queries): + reports = ReportFactory.create_batch(2) + + url = reverse('report-export') + + with django_assert_num_queries(2): + response = auth_client.get(url, data={'file_type': file_type}) + + assert response.status_code == status.HTTP_200_OK + book = pyexcel.get_book( + file_content=response.content, file_type=file_type + ) + # bookdict is a dict of tuples(name, content) + sheet = book.bookdict.popitem()[1] + assert len(sheet) == len(reports) + 1 From e747d69620f3b22b8a03728047b8c4f4ef7c2f53 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 3 Nov 2017 11:12:12 +0100 Subject: [PATCH 308/980] Add missing user included key on worktime balance serializer --- timed/employment/serializers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index f46fe33cd..573341dd1 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -91,6 +91,10 @@ def get_balance(self, instance): _, _, balance = instance.id.calculate_worktime(start, balance_date) return duration_string(balance) + included_serializers = { + 'user': 'timed.employment.serializers.UserSerializer' + } + class Meta: resource_name = 'worktime-balances' From 07d6def83096be2e56f461eccdb01c1df99ab5d9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 7 Nov 2017 14:37:18 +0100 Subject: [PATCH 309/980] Adjust so last_reported_date filter only returns days in the past --- timed/employment/serializers.py | 8 ++++---- timed/employment/tests/test_worktime_balance.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 573341dd1..62102c14c 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -70,10 +70,10 @@ def get_date(self, instance): return instance.date # calculate last reported day if no specific date is set - max_absence_date = Absence.objects.filter(user=user).exclude( - date=today).aggregate(date=Max('date')) - max_report_date = Report.objects.filter(user=user).exclude( - date=today).aggregate(date=Max('date')) + max_absence_date = Absence.objects.filter( + user=user, date__lt=today).aggregate(date=Max('date')) + max_report_date = Report.objects.filter( + user=user, date__lt=today).aggregate(date=Max('date')) last_reported_date = max( max_absence_date['date'] or date.min, diff --git a/timed/employment/tests/test_worktime_balance.py b/timed/employment/tests/test_worktime_balance.py index 487ac704d..8122ba05f 100644 --- a/timed/employment/tests/test_worktime_balance.py +++ b/timed/employment/tests/test_worktime_balance.py @@ -1,5 +1,6 @@ from datetime import date, timedelta +import pytest from django.core.urlresolvers import reverse from django.utils.duration import duration_string from rest_framework import status @@ -204,6 +205,7 @@ def test_worktime_balance_list_last_reported_date_no_reports( assert len(json['data']) == 0 +@pytest.mark.freeze_time('2017-02-02') def test_worktime_balance_list_last_reported_date( auth_client, django_assert_num_queries): @@ -220,6 +222,18 @@ def test_worktime_balance_list_last_reported_date( duration=timedelta(hours=10) ) + # reports today and in the future should be ignored + ReportFactory.create( + user=auth_client.user, + date=date(2017, 2, 2), + duration=timedelta(hours=10) + ) + ReportFactory.create( + user=auth_client.user, + date=date(2017, 2, 3), + duration=timedelta(hours=10) + ) + url = reverse('worktime-balance-list') with django_assert_num_queries(10): From 5d7f318668a601ba8c5e4e6e31e597a61cfb29ad Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 8 Nov 2017 10:14:56 +0100 Subject: [PATCH 310/980] May not add two fields to select related which point to same table This caused work reports result to be empty if verified_by was NULL. --- timed/reports/tests/test_work_report.py | 24 ++++++++++++++---------- timed/reports/views.py | 9 ++++++++- timed/tracking/filters.py | 3 ++- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 920041573..55279b242 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -14,7 +14,7 @@ @pytest.mark.freeze_time('2017-09-01') -def test_work_report_single_project(auth_client): +def test_work_report_single_project(auth_client, django_assert_num_queries): user = auth_client.user # spaces should be replaced with underscore customer = CustomerFactory.create(name='Customer Name') @@ -26,11 +26,13 @@ def test_work_report_single_project(auth_client): ) url = reverse('work-report-list') - res = auth_client.get(url, data={ - 'user': auth_client.user.id, - 'from_date': '2017-08-01', - 'to_date': '2017-08-31', - }) + with django_assert_num_queries(4): + res = auth_client.get(url, data={ + 'user': auth_client.user.id, + 'from_date': '2017-08-01', + 'to_date': '2017-08-31', + 'not_verified': 0 + }) assert res.status_code == HTTP_200_OK assert '1708-20170901-Customer_Name-Project.ods' in ( res['Content-Disposition'] @@ -46,7 +48,7 @@ def test_work_report_single_project(auth_client): @pytest.mark.freeze_time('2017-09-01') -def test_work_report_multiple_projects(auth_client): +def test_work_report_multiple_projects(auth_client, django_assert_num_queries): NUM_PROJECTS = 2 user = auth_client.user @@ -60,9 +62,11 @@ def test_work_report_multiple_projects(auth_client): ReportFactory.create_batch(10, user=user, task=task, date=report_date) url = reverse('work-report-list') - res = auth_client.get(url, data={ - 'user': auth_client.user.id - }) + with django_assert_num_queries(4): + res = auth_client.get(url, data={ + 'user': auth_client.user.id, + 'not_verified': 1 + }) assert res.status_code == HTTP_200_OK assert '20170901-WorkReports.zip' in ( res['Content-Disposition'] diff --git a/timed/reports/views.py b/timed/reports/views.py index 29c7f0f50..eb2d08940 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -139,7 +139,14 @@ class WorkReportViewSet(GenericViewSet): def get_queryset(self): return Report.objects.select_related( - 'user', 'verified_by' + 'user', 'task', 'task__project', 'task__project__customer' + ).prefetch_related( + # need to prefetch verified_by as select_related joins nullable + # foreign key verified_by with INNER JOIN instead of LEFT JOIN + # which leads to an empty result. + # This only happens as user and verified_by points to same table + # and user is not nullable + 'verified_by' ) def _parse_query_params(self, queryset, request): diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index bd8916c2b..6d502b78a 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -95,9 +95,10 @@ class ReportFilterSet(FilterSet): customer = NumberFilter(name='task__project__customer') review = NumberFilter(name='review') not_billable = NumberFilter(name='not_billable') - not_verified = NumberFilter(name='verified_by', lookup_expr='isnull') + not_verified = NumberFilter(name='verified_by_id', lookup_expr='isnull') reviewer = NumberFilter(name='task__project__reviewers') billing_type = NumberFilter(name='task__project__billing_type') + user = NumberFilter(name='user_id') cost_center = NumberFilter(name='cost_center', method='filter_cost_center') def filter_cost_center(self, queryset, name, value): From d03d4de582e7d3b0c83fe9442f6705a11aea6c49 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 8 Nov 2017 13:11:16 +0100 Subject: [PATCH 311/980] Add fill worktime filter to absence type view --- timed/employment/filters.py | 10 ++++++++++ timed/employment/tests/test_absence_type.py | 14 ++++++++++++++ timed/employment/views.py | 1 + 3 files changed, 25 insertions(+) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 15291697e..c2741f06a 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -37,6 +37,16 @@ class Meta: fields = ['year', 'location', 'date', 'from_date', 'to_date'] +class AbsenceTypeFilterSet(FilterSet): + fill_worktime = NumberFilter(name='fill_worktime') + + class Meta: + """Meta information for the public holiday filter set.""" + + model = models.AbsenceType + fields = ['fill_worktime'] + + class UserFilterSet(FilterSet): active = NumberFilter(name='is_active') supervisor = NumberFilter(name='supervisors') diff --git a/timed/employment/tests/test_absence_type.py b/timed/employment/tests/test_absence_type.py index 8f01a9e3f..c886106b4 100644 --- a/timed/employment/tests/test_absence_type.py +++ b/timed/employment/tests/test_absence_type.py @@ -16,6 +16,20 @@ def test_absence_type_list(auth_client): assert len(json['data']) == 2 +def test_absence_type_list_filter_fill_worktime(auth_client): + absence_type = AbsenceTypeFactory.create(fill_worktime=True) + AbsenceTypeFactory.create() + + url = reverse('absence-type-list') + + response = auth_client.get(url, data={'fill_worktime': 1}) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(absence_type.id) + + def test_absence_type_detail(auth_client): absence_type = AbsenceTypeFactory.create() diff --git a/timed/employment/views.py b/timed/employment/views.py index 52f237254..315b0b1f3 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -337,6 +337,7 @@ class AbsenceTypeViewSet(ReadOnlyModelViewSet): queryset = models.AbsenceType.objects.all() serializer_class = serializers.AbsenceTypeSerializer + filter_class = filters.AbsenceTypeFilterSet ordering = ('name',) From faceab4bc5e142e4966131fdf06468891c2057cf Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Wed, 8 Nov 2017 13:44:00 +0100 Subject: [PATCH 312/980] Remove employments from user serializer since it cannot be included --- timed/employment/serializers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 62102c14c..98a95b2a3 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -31,7 +31,6 @@ class Meta: model = get_user_model() fields = [ 'email', - 'employments', 'first_name', 'is_active', 'is_staff', @@ -43,7 +42,6 @@ class Meta: 'username', ] read_only_fields = [ - 'employments', 'first_name', 'is_active', 'is_staff', From d44bd33a119cdb1b2f78150a8763805a7b25b012 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 10:02:57 +0100 Subject: [PATCH 313/980] Bump to version 0.8.0 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index a71c5c7f1..32a90a3b9 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.7.0' +__version__ = '0.8.0' From a3d6766c059e75132e2344e8c71415db30ca3958 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 15:34:43 +0100 Subject: [PATCH 314/980] Reuse db when running pytest `make test` already forces creation of database but this changes increases performance when running tests on local machine. --- pytest.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/pytest.ini b/pytest.ini index c5f16e220..283ba4b6f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] DJANGO_SETTINGS_MODULE=timed.settings +addopts = --reuse-db From f7d0ed63edcbf15291936ac2612228dd7c5df526 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 16:49:13 +0100 Subject: [PATCH 315/980] Replace coveralls with codecov --- .travis.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index f454a081d..979a54583 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,6 @@ cache: install: - echo "ENV=travis" > .env - make install-dev - - pip install coveralls before_script: - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres @@ -22,4 +21,5 @@ before_script: script: make test -after_success: coveralls +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index 5d7078929..5c0d62fa5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Timed Backend [![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) -[![Coverage](https://img.shields.io/coveralls/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://coveralls.io/github/adfinis-sygroup/timed-backend) +[![Coverage](https://img.shields.io/codecov/c/github/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://codecov.io/gh/adfinis-sygroup/timed-frontend) [![License](https://img.shields.io/github/license/adfinis-sygroup/timed-frontend.svg?style=flat-square)](LICENSE) Timed timetracking software REST API built with Django From 1021105a57c3f1a7a139c7badbb13da5c10e24c6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 17:03:25 +0100 Subject: [PATCH 316/980] Show master badge of built failure --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c0d62fa5..951303eda 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Timed Backend -[![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) +[![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) [![Coverage](https://img.shields.io/codecov/c/github/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://codecov.io/gh/adfinis-sygroup/timed-frontend) [![License](https://img.shields.io/github/license/adfinis-sygroup/timed-frontend.svg?style=flat-square)](LICENSE) From 74442beaaa4979dde6c4ca1459b5070a8205e49e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 17:26:02 +0100 Subject: [PATCH 317/980] Replace frontend links with backend links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 951303eda..dca3c7b01 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Timed Backend [![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) -[![Coverage](https://img.shields.io/codecov/c/github/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://codecov.io/gh/adfinis-sygroup/timed-frontend) -[![License](https://img.shields.io/github/license/adfinis-sygroup/timed-frontend.svg?style=flat-square)](LICENSE) +[![Coverage](https://img.shields.io/codecov/c/github/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://codecov.io/gh/adfinis-sygroup/timed-backend) +[![License](https://img.shields.io/github/license/adfinis-sygroup/timed-backend.svg?style=flat-square)](LICENSE) Timed timetracking software REST API built with Django From cb1d3e2c0267019dbf99372b17189c1e02a6f958 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 10 Nov 2017 09:12:22 +0100 Subject: [PATCH 318/980] Remove batch verify This will be replaced in a future PR with bulk update --- timed/tracking/tests/test_report.py | 52 ----------------------------- timed/tracking/views.py | 20 ----------- 2 files changed, 72 deletions(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 9926781f6..51c16de26 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -52,58 +52,6 @@ def test_report_list_filter_reviewer(auth_client): assert json['data'][0]['id'] == str(report.id) -def test_report_list_verify(admin_client): - user = admin_client.user - report = ReportFactory.create(user=user, duration=timedelta(hours=1)) - ReportFactory.create() - - url_list = reverse('report-list') - response = admin_client.get(url_list, data={'not_verified': 1}) - assert response.status_code == status.HTTP_200_OK - json = response.json() - assert len(json['data']) == 2 - - url_verify = reverse('report-verify') - response = admin_client.post(url_verify, QUERY_STRING='user=%s' % user.id) - assert response.status_code == status.HTTP_200_OK - - response = admin_client.get(url_list, data={'not_verified': 0}) - assert response.status_code == status.HTTP_200_OK - - json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(report.id) - assert json['meta']['total-time'] == '01:00:00' - - -def test_report_list_verify_non_admin(auth_client): - """Non admin resp. non staff user may not verify reports.""" - user = auth_client.user - url_verify = reverse('report-verify') - - res = auth_client.post(url_verify, QUERY_STRING='user=%s' % user.id) - assert res.status_code == status.HTTP_403_FORBIDDEN - - -def test_report_list_verify_page(admin_client): - user = admin_client.user - ReportFactory.create_batch(2, user=user) - - url_verify = reverse('report-verify') - response = admin_client.post( - url_verify, - QUERY_STRING='user=%s&page_size=1' % user.id - ) - assert response.status_code == status.HTTP_200_OK - - url_list = reverse('report-list') - response = admin_client.get(url_list, data={'not_verified': 0}) - assert response.status_code == status.HTTP_200_OK - - json = response.json() - assert len(json['data']) == 1 - - def test_report_export_missing_type(auth_client): user = auth_client.user url = reverse('report-export') diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 8395b54d1..39e195fa4 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -5,7 +5,6 @@ from django.http import HttpResponseBadRequest from rest_condition import C from rest_framework.decorators import list_route -from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from timed.permissions import (IsAdminUser, IsAuthenticated, IsDeleteOnly, @@ -167,25 +166,6 @@ def export(self, request): sheet, file_type=file_type, file_name='report.%s' % file_type ) - @list_route(methods=['post'], url_path='verify', - permission_classes=[IsAuthenticated, IsAdminUser]) - def verify_list(self, request): - """ - Bulk verify all reports by given filter. - - Authenticated user will be set as verified_by on given - reports. - """ - queryset = self.filter_queryset(self.get_queryset()) - page = self.paginate_queryset(queryset) - if page is not None: - # page is a list so need to convert it to queryset - ids = [report.id for report in page] - queryset = models.Report.objects.filter(id__in=ids) - queryset.update(verified_by=request.user) - - return Response(data={}) - def get_queryset(self): """Select related to reduce queries. From 36fe229b0c95857df0559252b1dd28d79adbad1d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 09:49:49 +0100 Subject: [PATCH 319/980] Add report-intersection end point Returns a report-intersection resource with fields which are equal in result. If result has different values of one field None will be returned. --- timed/mixins.py | 16 +----- timed/serializers.py | 13 +++++ timed/tracking/serializers.py | 87 ++++++++++++++++++++++++++++- timed/tracking/tests/test_report.py | 85 ++++++++++++++++++++++++++++ timed/tracking/views.py | 32 ++++++++++- 5 files changed, 217 insertions(+), 16 deletions(-) diff --git a/timed/mixins.py b/timed/mixins.py index cd61ee4f7..63dcf617b 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -1,5 +1,7 @@ from rest_framework_json_api import relations +from timed.serializers import AggregateObject + class AggregateQuerysetMixin(object): """ @@ -18,18 +20,6 @@ class AggregateQuerysetMixin(object): field defined in the serializer. """ - class AggregateObject(dict): - """ - Wrap dict into an object. - - All values will be accessible through attributes. Note that - keys must be valid python names for this to work. - """ - - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - super().__init__(**kwargs) - def _is_related_field(self, val): """ Check whether value is a related field. @@ -70,7 +60,7 @@ def get_serializer(self, data, *args, **kwargs): # enhance entry dicts with model instances data = [ - self.AggregateObject(**{ + AggregateObject(**{ **entry, **{ field: objects[entry[field]] diff --git a/timed/serializers.py b/timed/serializers.py index 70e3440e5..ae36977f1 100644 --- a/timed/serializers.py +++ b/timed/serializers.py @@ -18,3 +18,16 @@ def get_root_meta(self, resource, many): ) return data return {} + + +class AggregateObject(dict): + """ + Wrap dict into an object. + + All values will be accessible through attributes. Note that + keys must be valid python names for this to work. + """ + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + super().__init__(**kwargs) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index d804fba6f..01650b669 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -2,17 +2,19 @@ from datetime import timedelta from django.contrib.auth import get_user_model +from django.db.models import BooleanField, Case, When from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ +from rest_framework_json_api import relations from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, DurationField, - ModelSerializer, + ModelSerializer, Serializer, SerializerMethodField, ValidationError) from timed.employment.models import AbsenceType, Employment, PublicHoliday -from timed.projects.models import Task +from timed.projects.models import Customer, Project, Task from timed.serializers import TotalTimeRootMetaMixin from timed.tracking import models @@ -188,6 +190,87 @@ class Meta: ] +class ReportIntersectionSerializer(Serializer): + """Serializers intersection of given reports.""" + + customer = relations.SerializerMethodResourceRelatedField( + source='get_customer', + model=Customer, + read_only=True + ) + project = relations.SerializerMethodResourceRelatedField( + source='get_project', + model=Project, + read_only=True + ) + task = relations.SerializerMethodResourceRelatedField( + source='get_task', + model=Task, + read_only=True + ) + comment = SerializerMethodField() + review = SerializerMethodField() + not_billable = SerializerMethodField() + not_verified = SerializerMethodField() + + def _intersection(self, instance, field, model=None): + """Get intersection of given field. + + :return: Returns value of field if objects have same value; + otherwise None + """ + value = None + queryset = instance['queryset'] + values = queryset.values(field).distinct() + if values.count() == 1: + value = values.first()[field] + if model: + value = model.objects.get(pk=value) + + return value + + def get_customer(self, instance): + return self._intersection( + instance, 'task__project__customer', Customer + ) + + def get_project(self, instance): + return self._intersection(instance, 'task__project', Project) + + def get_task(self, instance): + return self._intersection(instance, 'task', Task) + + def get_comment(self, instance): + return self._intersection(instance, 'comment') + + def get_review(self, instance): + return self._intersection(instance, 'review') + + def get_not_billable(self, instance): + return self._intersection(instance, 'not_billable') + + def get_not_verified(self, instance): + queryset = instance['queryset'] + queryset = queryset.annotate( + not_verified=Case( + When(verified_by_id__isnull=True, then=True), + default=False, + output_field=BooleanField() + ) + ) + instance['queryset'] = queryset + return self._intersection(instance, 'not_verified') + + included_serializers = { + 'customer': 'timed.projects.serializers.CustomerSerializer', + 'project': 'timed.projects.serializers.ProjectSerializer', + 'task': 'timed.projects.serializers.TaskSerializer', + } + + class Meta: + resource_name = 'report-intersections' + + class AbsenceSerializer(ModelSerializer): """Absence serializer.""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 51c16de26..4fbadb5bd 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -38,6 +38,91 @@ def test_report_list(auth_client): assert json['meta']['total-time'] == '01:00:00' +def test_report_intersection_full(auth_client): + report = ReportFactory.create() + + url = reverse('report-intersection') + response = auth_client.get(url, data={ + 'ordering': 'task__name', + 'task': report.task.id, + 'project': report.task.project.id, + 'customer': report.task.project.customer.id, + 'include': 'task,customer,project' + }) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + pk = json['data'].pop('id') + assert 'task={0}'.format(report.task.id) in pk + assert 'project={0}'.format(report.task.project.id) in pk + assert 'customer={0}'.format(report.task.project.customer.id) in pk + + included = json.pop('included') + assert len(included) == 3 + + expected = { + 'data': { + 'type': 'report-intersections', + 'attributes': { + 'comment': report.comment, + 'not-billable': False, + 'not-verified': True, + 'review': False + }, + 'relationships': { + 'customer': { + 'data': { + 'id': str(report.task.project.customer.id), + 'type': 'customers' + } + }, + 'project': { + 'data': { + 'id': str(report.task.project.id), + 'type': 'projects' + } + }, + 'task': { + 'data': { + 'id': str(report.task.id), + 'type': 'tasks', + } + } + } + } + } + assert json == expected + + +def test_report_intersection_partial(auth_client): + user = auth_client.user + ReportFactory.create(review=True, not_billable=True, comment='test') + ReportFactory.create(verified_by=user, comment='test') + + url = reverse('report-intersection') + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + expected = { + 'data': { + 'id': '', 'type': 'report-intersections', + 'attributes': { + 'comment': 'test', + 'not-billable': None, + 'not-verified': None, + 'review': None + }, + 'relationships': { + 'customer': {'data': None}, + 'project': {'data': None}, + 'task': {'data': None} + } + } + } + assert json == expected + + def test_report_list_filter_reviewer(auth_client): user = auth_client.user report = ReportFactory.create(user=user) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 39e195fa4..b10f6a80c 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -5,10 +5,12 @@ from django.http import HttpResponseBadRequest from rest_condition import C from rest_framework.decorators import list_route +from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from timed.permissions import (IsAdminUser, IsAuthenticated, IsDeleteOnly, IsOwner, IsReadOnly, IsSuperUser, IsUnverified) +from timed.serializers import AggregateObject from timed.tracking import filters, models, serializers @@ -127,7 +129,35 @@ def _extract_billing_type(self, report): return name - @list_route() + @list_route( + methods=['get'], + serializer_class=serializers.ReportIntersectionSerializer, + ) + def intersection(self, request): + """Get intersection in result of common report fields.""" + queryset = self.get_queryset() + queryset = self.filter_queryset(queryset) + + # filter params represent main indication of result + # so it can be used as id + params = self.request.query_params.copy() + ignore_params = { + 'ordering', + 'page', + 'page_size', + 'include' + } + for param in ignore_params.intersection(params.keys()): + del params[param] + + data = AggregateObject(**{ + 'queryset': queryset, + 'pk': params.urlencode() + }) + serializer = serializers.ReportIntersectionSerializer(data) + return Response(data=serializer.data) + + @list_route(methods=['get']) def export(self, request): """Export filtered reports to given file format.""" queryset = self.get_queryset().select_related( From 25f446542165c3b6bd8f6833615f1a59acb4df0a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 16:28:25 +0100 Subject: [PATCH 320/980] Clarify intersection documentation --- timed/tracking/serializers.py | 10 +++++++++- timed/tracking/views.py | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 01650b669..8017776da 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -191,7 +191,15 @@ class Meta: class ReportIntersectionSerializer(Serializer): - """Serializers intersection of given reports.""" + """ + Serializer of report intersections. + + Serializes a representation of all fields which are the same + in given Report objects. If values of one field are not the same + in all objects it will be represented as None. + + Serializer expect instance to have a queryset value. + """ customer = relations.SerializerMethodResourceRelatedField( source='get_customer', diff --git a/timed/tracking/views.py b/timed/tracking/views.py index b10f6a80c..ed340b49e 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -134,7 +134,14 @@ def _extract_billing_type(self, report): serializer_class=serializers.ReportIntersectionSerializer, ) def intersection(self, request): - """Get intersection in result of common report fields.""" + """ + Get intersection in reports of common report fields. + + Use case is for api caller to know what fields are the same + in a list of reports. This will be mainly used for bulk update. + + This will always return a single resource. + """ queryset = self.get_queryset() queryset = self.filter_queryset(queryset) From 048a166b1d1f31911c4e2b0f9b0164f2db5e334c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Nov 2017 16:36:44 +0100 Subject: [PATCH 321/980] Remove outer dict as AggregateObject accepts kwargs. --- timed/tracking/views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index ed340b49e..b3982cfb3 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -157,10 +157,7 @@ def intersection(self, request): for param in ignore_params.intersection(params.keys()): del params[param] - data = AggregateObject(**{ - 'queryset': queryset, - 'pk': params.urlencode() - }) + data = AggregateObject(queryset=queryset, pk=params.urlencode()) serializer = serializers.ReportIntersectionSerializer(data) return Response(data=serializer.data) From 39146059f83bb52f942b78249d0c6b41ba8d3a19 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 10 Nov 2017 10:00:54 +0100 Subject: [PATCH 322/980] Allow only reviewer or superuser to verify reports --- timed/tracking/serializers.py | 45 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 8017776da..40ee56910 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -145,24 +145,6 @@ def validate_date(self, value): return value - def validate_verified_by(self, value): - user = self.context['request'].user - current_verified_by = self.instance and self.instance.verified_by - - if value == current_verified_by: - # no validation needed when nothing has changed - return value - - if value is None and user.is_staff: - # staff is allowed to reset verified by - return value - - if value is not None and user.is_staff and value == user: - # staff user is allowed to set it's own user as verified by - return value - - raise ValidationError(_('Only staff user may verify reports.')) - def validate_duration(self, value): """Only owner is allowed to change duration.""" if self.instance is not None: @@ -173,9 +155,30 @@ def validate_duration(self, value): return value - class Meta: - """Meta information for the report serializer.""" + def validate(self, data): + """Validate that verified by is only set by reviewer or superuser.""" + user = self.context['request'].user + current_verified_by = self.instance and self.instance.verified_by + new_verified_by = data.get('verified_by') + task = data.get('task') or self.instance.task + + if new_verified_by != current_verified_by: + is_reviewer = ( + user.is_superuser or + task.project.reviewers.filter(id=user.id).exists() + ) + + if not is_reviewer: + raise ValidationError(_('Only reviewer may verify reports.')) + + if new_verified_by is not None and new_verified_by != user: + raise ValidationError( + _('You may only verifiy with your own user') + ) + return data + + class Meta: model = models.Report fields = [ 'comment', @@ -183,10 +186,10 @@ class Meta: 'duration', 'review', 'not_billable', - 'verified_by', 'task', 'activity', 'user', + 'verified_by', ] From 5fd7a64114d5a4913ccaf72414cb129a82c00e4e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 10 Nov 2017 10:19:08 +0100 Subject: [PATCH 323/980] Allow superuser, reviewer or supervisor to change reports Owner may of course still change his reports as before --- timed/permissions.py | 16 +++--- timed/tracking/tests/test_report.py | 81 ++++++++++++++++++++++++----- timed/tracking/views.py | 9 ++-- 3 files changed, 78 insertions(+), 28 deletions(-) diff --git a/timed/permissions.py b/timed/permissions.py index dc3f6c5ea..48d330aa8 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -1,5 +1,5 @@ from rest_framework.permissions import (SAFE_METHODS, BasePermission, - IsAdminUser, IsAuthenticated) + IsAuthenticated) class IsUnverified(BasePermission): @@ -53,7 +53,7 @@ class IsAuthenticated(IsAuthenticated): """ Support mixing permission IsAuthenticated with object permission. - This is needed to use IsAdminUser with rest condition and or + This is needed to use IsAuthenticated with rest condition and or operator. """ @@ -75,16 +75,12 @@ def has_object_permission(self, request, view, obj): return request.user.supervisees.filter(id=obj.user_id).exists() -class IsAdminUser(IsAdminUser): - """ - Support mixing permission IsAdminUser with object permission. - - This is needed to use IsAdminUser with rest condition and or - operator. - """ +class IsReviewer(IsAuthenticated): + """Allows access to object only to reviewers.""" def has_object_permission(self, request, view, obj): - return self.has_permission(request, view) + user = request.user + return obj.task.project.reviewers.filter(id=user.id).exists() class IsSuperUser(BasePermission): diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 4fbadb5bd..e071a3ecc 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -8,6 +8,7 @@ from django.utils.duration import duration_string from rest_framework import status +from timed.employment.factories import UserFactory from timed.projects.factories import (CostCenterFactory, ProjectFactory, TaskFactory) from timed.tracking.factories import ReportFactory @@ -272,8 +273,10 @@ def test_report_update_owner(auth_client): ) -def test_report_update_date_staff(admin_client): +def test_report_update_date_reviewer(auth_client): + user = auth_client.user report = ReportFactory.create() + report.task.project.reviewers.add(user) data = { 'data': { @@ -287,12 +290,14 @@ def test_report_update_date_staff(admin_client): url = reverse('report-detail', args=[report.id]) - response = admin_client.patch(url, data) + response = auth_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_duration_staff(admin_client): +def test_report_update_duration_reviewer(auth_client): + user = auth_client.user report = ReportFactory.create(duration=timedelta(hours=2)) + report.task.project.reviewers.add(user) data = { 'data': { @@ -308,11 +313,11 @@ def test_report_update_duration_staff(admin_client): report.id ]) - res = admin_client.patch(url, data) + res = auth_client.patch(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_not_staff_user(auth_client): +def test_report_update_by_user(auth_client): """Updating of report belonging to different user is not allowed.""" report = ReportFactory.create() data = { @@ -330,8 +335,8 @@ def test_report_update_not_staff_user(auth_client): assert response.status_code == status.HTTP_403_FORBIDDEN -def test_report_set_verified_by_not_staff_user(auth_client): - """Not staff user may not set verified by.""" +def test_report_set_verified_by_user(auth_client): + """Test that normal user may not verify report.""" user = auth_client.user report = ReportFactory.create(user=user) data = { @@ -354,9 +359,10 @@ def test_report_set_verified_by_not_staff_user(auth_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_staff_user(admin_client): - user = admin_client.user +def test_report_update_reviewer(auth_client): + user = auth_client.user report = ReportFactory.create(user=user) + report.task.project.reviewers.add(user) data = { 'data': { @@ -378,14 +384,61 @@ def test_report_update_staff_user(admin_client): url = reverse('report-detail', args=[report.id]) - response = admin_client.patch(url, data) + response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK -def test_report_reset_verified_by_staff_user(admin_client): - """Staff user may reset verified by on report.""" - user = admin_client.user +def test_report_update_supervisor(auth_client): + user = auth_client.user + report = ReportFactory.create(user=user) + report.user.supervisors.add(user) + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'attributes': { + 'comment': 'foobar', + }, + } + } + + url = reverse('report-detail', args=[report.id]) + + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + +def test_report_verify_other_user(superadmin_client): + """Verify that superuser may not verify to other user.""" + user = UserFactory.create() + report = ReportFactory.create() + + data = { + 'data': { + 'type': 'reports', + 'id': report.id, + 'relationships': { + 'verified-by': { + 'data': { + 'id': user.id, + 'type': 'users' + } + }, + } + } + } + + url = reverse('report-detail', args=[report.id]) + response = superadmin_client.patch(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_report_reset_verified_by_reviewer(auth_client): + """Test that reviewer may reset verified by on report.""" + user = auth_client.user report = ReportFactory.create(user=user, verified_by=user) + report.task.project.reviewers.add(user) data = { 'data': { @@ -403,7 +456,7 @@ def test_report_reset_verified_by_staff_user(admin_client): } url = reverse('report-detail', args=[report.id]) - response = admin_client.patch(url, data) + response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK diff --git a/timed/tracking/views.py b/timed/tracking/views.py index b3982cfb3..931999eac 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -8,8 +8,9 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from timed.permissions import (IsAdminUser, IsAuthenticated, IsDeleteOnly, - IsOwner, IsReadOnly, IsSuperUser, IsUnverified) +from timed.permissions import (IsAuthenticated, IsDeleteOnly, IsOwner, + IsReadOnly, IsReviewer, IsSuperUser, + IsSupervisor, IsUnverified) from timed.serializers import AggregateObject from timed.tracking import filters, models, serializers @@ -82,8 +83,8 @@ class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet permission_classes = [ - # admin user can change all but not delete - C(IsAuthenticated) & C(IsAdminUser) & ~C(IsDeleteOnly) | + # reviewer, supervisor or superuser may change but not delete reports + (C(IsSuperUser) | C(IsReviewer) | C(IsSupervisor)) & ~C(IsDeleteOnly) | # owner may only change its own unverified reports C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) | # all authenticated users may read all reports From d905bd70350f7d409f8abe33110fcd369ff7b5ba Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 10 Nov 2017 11:44:11 +0100 Subject: [PATCH 324/980] Add editable filter Filters reports whether they are editable by user. Only owner, superuser, reviewers or supervisor may change a report. --- timed/tracking/filters.py | 33 ++++++++++- timed/tracking/tests/test_report.py | 85 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 6d502b78a..478ce3ca8 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -94,12 +94,43 @@ class ReportFilterSet(FilterSet): project = NumberFilter(name='task__project') customer = NumberFilter(name='task__project__customer') review = NumberFilter(name='review') + editable = NumberFilter(method='filter_editable') not_billable = NumberFilter(name='not_billable') not_verified = NumberFilter(name='verified_by_id', lookup_expr='isnull') reviewer = NumberFilter(name='task__project__reviewers') billing_type = NumberFilter(name='task__project__billing_type') user = NumberFilter(name='user_id') - cost_center = NumberFilter(name='cost_center', method='filter_cost_center') + cost_center = NumberFilter(method='filter_cost_center') + + def filter_editable(self, queryset, name, value): + """Filter reports whether there are editable by current user.""" + user = self.request.user + + if value: # editable + if user.is_superuser: + # superuser may edit all reports + return queryset + + # only owner, reviewer or supervisor may change reports + queryset = queryset.filter( + Q(user__supervisors=user) | + Q(task__project__reviewers=user) | + Q(user=user) + ) + + return queryset + else: # not editable + if user.is_superuser: + # no reports which are not editable + return queryset.none() + + queryset = queryset.exclude( + Q(user__supervisors=user) | + Q(task__project__reviewers=user) | + Q(user=user) + ) + + return queryset def filter_cost_center(self, queryset, name, value): """ diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index e071a3ecc..1e0cd68e2 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -138,6 +138,91 @@ def test_report_list_filter_reviewer(auth_client): assert json['data'][0]['id'] == str(report.id) +def test_report_list_filter_editable_owner(auth_client): + user = auth_client.user + report = ReportFactory.create(user=user) + ReportFactory.create() + + url = reverse('report-list') + + response = auth_client.get(url, data={'editable': 1}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(report.id) + + +def test_report_list_filter_not_editable_owner(auth_client): + user = auth_client.user + ReportFactory.create(user=user) + report = ReportFactory.create() + + url = reverse('report-list') + + response = auth_client.get(url, data={'editable': 0}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(report.id) + + +def test_report_list_filter_editable_reviewer(auth_client): + user = auth_client.user + # not editable report + ReportFactory.create() + # editable reports + ReportFactory.create(user=user) + report = ReportFactory.create() + report.task.project.reviewers.add(user) + + url = reverse('report-list') + + response = auth_client.get(url, data={'editable': 1}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 2 + + +def test_report_list_filter_editable_superuser(superadmin_client): + report = ReportFactory.create() + + url = reverse('report-list') + + response = superadmin_client.get(url, data={'editable': 1}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(report.id) + + +def test_report_list_filter_not_editable_superuser(superadmin_client): + ReportFactory.create() + + url = reverse('report-list') + + response = superadmin_client.get(url, data={'editable': 0}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 0 + + +def test_report_list_filter_editable_supervisor(auth_client): + user = auth_client.user + # not editable report + ReportFactory.create() + # editable reports + ReportFactory.create(user=user) + report = ReportFactory.create() + report.user.supervisors.add(user) + + url = reverse('report-list') + + response = auth_client.get(url, data={'editable': 1}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 2 + + def test_report_export_missing_type(auth_client): user = auth_client.user url = reverse('report-export') From 104fea2168e3258c375a2d718c181f49aa744bea Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 13 Nov 2017 10:09:09 +0100 Subject: [PATCH 325/980] Clarify editable filter --- timed/tracking/filters.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 478ce3ca8..55d38c491 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -103,7 +103,11 @@ class ReportFilterSet(FilterSet): cost_center = NumberFilter(method='filter_cost_center') def filter_editable(self, queryset, name, value): - """Filter reports whether there are editable by current user.""" + """Filter reports whether they are editable by current user. + + When set to `1` filter all results to what is editable by current + user. If set to `0` to not editable. + """ user = self.request.user if value: # editable From c51ca8dad9f79bd6d04332cef231cb029df230f6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 10 Nov 2017 14:40:44 +0100 Subject: [PATCH 326/980] Add custom action to bulk update reports --- timed/reports/tests/test_work_report.py | 4 +- timed/tracking/filters.py | 6 +- timed/tracking/serializers.py | 29 ++++-- timed/tracking/tests/test_report.py | 117 +++++++++++++++++++++++- timed/tracking/views.py | 51 +++++++++++ 5 files changed, 194 insertions(+), 13 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 55279b242..0f464c4bb 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -31,7 +31,7 @@ def test_work_report_single_project(auth_client, django_assert_num_queries): 'user': auth_client.user.id, 'from_date': '2017-08-01', 'to_date': '2017-08-31', - 'not_verified': 0 + 'verified': 1 }) assert res.status_code == HTTP_200_OK assert '1708-20170901-Customer_Name-Project.ods' in ( @@ -65,7 +65,7 @@ def test_work_report_multiple_projects(auth_client, django_assert_num_queries): with django_assert_num_queries(4): res = auth_client.get(url, data={ 'user': auth_client.user.id, - 'not_verified': 1 + 'verified': 0 }) assert res.status_code == HTTP_200_OK assert '20170901-WorkReports.zip' in ( diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 55d38c491..b81c845df 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -96,7 +96,9 @@ class ReportFilterSet(FilterSet): review = NumberFilter(name='review') editable = NumberFilter(method='filter_editable') not_billable = NumberFilter(name='not_billable') - not_verified = NumberFilter(name='verified_by_id', lookup_expr='isnull') + verified = NumberFilter( + name='verified_by_id', lookup_expr='isnull', exclude=True + ) reviewer = NumberFilter(name='task__project__reviewers') billing_type = NumberFilter(name='task__project__billing_type') user = NumberFilter(name='user_id') @@ -160,7 +162,7 @@ class Meta: 'user', 'task', 'project', - 'not_verified', + 'verified', 'not_billable', 'review', 'reviewer', diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 40ee56910..0f9a2d763 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -5,7 +5,7 @@ from django.db.models import BooleanField, Case, When from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ -from rest_framework_json_api import relations +from rest_framework_json_api import relations, serializers from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CurrentUserDefault, DurationField, @@ -193,6 +193,21 @@ class Meta: ] +class ReportBulkSerializer(Serializer): + """Serializer used for bulk updates of reports.""" + + task = ResourceRelatedField( + queryset=Task.objects.all(), allow_null=True, required=False + ) + comment = serializers.CharField(allow_null=True, required=False) + review = serializers.NullBooleanField(required=False) + not_billable = serializers.NullBooleanField(required=False) + verified = serializers.NullBooleanField(required=False) + + class Meta: + resource_name = 'report-bulks' + + class ReportIntersectionSerializer(Serializer): """ Serializer of report intersections. @@ -222,7 +237,7 @@ class ReportIntersectionSerializer(Serializer): comment = SerializerMethodField() review = SerializerMethodField() not_billable = SerializerMethodField() - not_verified = SerializerMethodField() + verified = SerializerMethodField() def _intersection(self, instance, field, model=None): """Get intersection of given field. @@ -260,17 +275,17 @@ def get_review(self, instance): def get_not_billable(self, instance): return self._intersection(instance, 'not_billable') - def get_not_verified(self, instance): + def get_verified(self, instance): queryset = instance['queryset'] queryset = queryset.annotate( - not_verified=Case( - When(verified_by_id__isnull=True, then=True), - default=False, + verified=Case( + When(verified_by_id__isnull=True, then=False), + default=True, output_field=BooleanField() ) ) instance['queryset'] = queryset - return self._intersection(instance, 'not_verified') + return self._intersection(instance, 'verified') included_serializers = { 'customer': 'timed.projects.serializers.CustomerSerializer', diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 1e0cd68e2..75abe6450 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -67,7 +67,7 @@ def test_report_intersection_full(auth_client): 'attributes': { 'comment': report.comment, 'not-billable': False, - 'not-verified': True, + 'verified': False, 'review': False }, 'relationships': { @@ -111,7 +111,7 @@ def test_report_intersection_partial(auth_client): 'attributes': { 'comment': 'test', 'not-billable': None, - 'not-verified': None, + 'verified': None, 'review': None }, 'relationships': { @@ -283,6 +283,119 @@ def test_report_create(auth_client): assert json['data']['relationships']['task']['data']['id'] == str(task.id) +def test_report_update_bulk(auth_client): + task = TaskFactory.create() + report = ReportFactory.create(user=auth_client.user) + + url = reverse('report-bulk') + + data = { + 'data': { + 'type': 'report-bulks', + 'id': None, + 'relationships': { + 'task': { + 'data': { + 'type': 'tasks', + 'id': task.id + } + } + }, + } + } + + response = auth_client.post(url + '?editable=1', data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + report.refresh_from_db() + assert report.task == task + + +def test_report_update_bulk_verify_non_reviewer(auth_client): + ReportFactory.create(user=auth_client.user) + + url = reverse('report-bulk') + + data = { + 'data': { + 'type': 'report-bulks', + 'id': None, + 'attributes': { + 'verified': True + } + } + } + + response = auth_client.post(url + '?editable=1', data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_report_update_bulk_verify_superuser(superadmin_client): + user = superadmin_client.user + report = ReportFactory.create(user=user) + + url = reverse('report-bulk') + + data = { + 'data': { + 'type': 'report-bulks', + 'id': None, + 'attributes': { + 'verified': True + } + } + } + + response = superadmin_client.post(url + '?editable=1', data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + report.refresh_from_db() + assert report.verified_by == user + + +def test_report_update_bulk_reset_verify_reviewer(auth_client): + user = auth_client.user + report = ReportFactory.create(verified_by=user) + report.task.project.reviewers.add(user) + + url = reverse('report-bulk') + + data = { + 'data': { + 'type': 'report-bulks', + 'id': None, + 'attributes': { + 'verified': False + } + } + } + + response = auth_client.post( + url + '?editable=1&reviewer={0}'.format(user.id), data + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + report.refresh_from_db() + assert report.verified_by_id is None + + +def test_report_update_bulk_not_editable(auth_client): + url = reverse('report-bulk') + + data = { + 'data': { + 'type': 'report-bulks', + 'id': None, + 'attributes': { + 'not_billable': True + }, + } + } + + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_report_update_verified_as_non_staff_but_owner(auth_client): """Test that an owner (not staff) may not change a verified report.""" user = auth_client.user diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 931999eac..7584c0d87 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,9 +1,12 @@ """Viewsets for the tracking app.""" import django_excel +from django.db import transaction from django.db.models import Q from django.http import HttpResponseBadRequest +from django.utils.translation import ugettext_lazy as _ from rest_condition import C +from rest_framework import exceptions, status from rest_framework.decorators import list_route from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -162,6 +165,54 @@ def intersection(self, request): serializer = serializers.ReportIntersectionSerializer(data) return Response(data=serializer.data) + @list_route( + methods=['post'], + # all users are allowed to bulk update but only on filtered result + permission_classes=[IsAuthenticated], + serializer_class=serializers.ReportBulkSerializer + ) + def bulk(self, request): + user = request.user + queryset = self.get_queryset() + queryset = self.filter_queryset(queryset) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + fields = { + key: value + for key, value in serializer.validated_data.items() + # value equal None means do not touch whereas verified + # is handled separately + if value is not None and key != 'verified' + } + + with transaction.atomic(): + verified = serializer.validated_data.get('verified') + if verified is not None: + # only reviewer or superuser may verify reports + # this is enforced when reviewer filter is set to current user + reviewer_id = request.query_params.get('reviewer') + if not user.is_superuser and str(reviewer_id) != str(user.id): + raise exceptions.ParseError( + _('You do not have the permission to verify reports') + ) + + verified_by = verified and user or None + queryset.filter(verified_by__isnull=verified).update( + verified_by=verified_by + ) + + # update all other fields + if fields: + if not request.query_params.get('editable'): + raise exceptions.ParseError( + _('Editable filter needs to be set for bulk update') + ) + + queryset.update(**fields) + + return Response(status=status.HTTP_204_NO_CONTENT) + @list_route(methods=['get']) def export(self, request): """Export filtered reports to given file format.""" From 6095bd0117a3c0a09835c53e6bf5ef57f6577757 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 13 Nov 2017 11:42:08 +0100 Subject: [PATCH 327/980] Add id list filter --- timed/filters.py | 13 +++++++++++++ timed/tracking/filters.py | 2 ++ timed/tracking/tests/test_report.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 timed/filters.py diff --git a/timed/filters.py b/timed/filters.py new file mode 100644 index 000000000..28f99aafb --- /dev/null +++ b/timed/filters.py @@ -0,0 +1,13 @@ +from django_filters import Filter + + +class ListFilter(Filter): + """List filter expecting list of values as comma separated values.""" + + def filter(self, qs, value): + if not value: + return qs + + self.lookup_expr = 'in' + values = value.split(',') + return super().filter(qs, values) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index b81c845df..1b8c77852 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -5,6 +5,7 @@ from django.db.models import Q from django_filters import DateFilter, Filter, FilterSet, NumberFilter +from timed.filters import ListFilter from timed.tracking import models @@ -89,6 +90,7 @@ class Meta: class ReportFilterSet(FilterSet): """Filter set for the reports endpoint.""" + id = ListFilter() from_date = DateFilter(name='date', lookup_expr='gte') to_date = DateFilter(name='date', lookup_expr='lte') project = NumberFilter(name='task__project') diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 75abe6450..c9b845367 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -124,6 +124,36 @@ def test_report_intersection_partial(auth_client): assert json == expected +def test_report_list_filter_id(auth_client): + report_1 = ReportFactory.create(date='2017-01-01') + report_2 = ReportFactory.create(date='2017-02-01') + ReportFactory.create() + + url = reverse('report-list') + + response = auth_client.get(url, data={ + 'id': '{0},{1}'.format(report_1.id, report_2.id), + 'ordering': 'id' + }) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 2 + assert json['data'][0]['id'] == str(report_1.id) + assert json['data'][1]['id'] == str(report_2.id) + + +def test_report_list_filter_id_empty(auth_client): + """Test that empty id filter is ignored.""" + ReportFactory.create() + + url = reverse('report-list') + + response = auth_client.get(url, data={'id': ''}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + + def test_report_list_filter_reviewer(auth_client): user = auth_client.user report = ReportFactory.create(user=user) From ceb3452063b7fdd5760dd7f7156fd71602a456d8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 13 Nov 2017 11:45:14 +0100 Subject: [PATCH 328/980] Add verifier filter to report end point --- timed/tracking/filters.py | 1 + timed/tracking/tests/test_report.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 1b8c77852..3f93dc691 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -102,6 +102,7 @@ class ReportFilterSet(FilterSet): name='verified_by_id', lookup_expr='isnull', exclude=True ) reviewer = NumberFilter(name='task__project__reviewers') + verifier = NumberFilter(name='verified_by') billing_type = NumberFilter(name='task__project__billing_type') user = NumberFilter(name='user_id') cost_center = NumberFilter(method='filter_cost_center') diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index c9b845367..7770fe745 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -168,6 +168,20 @@ def test_report_list_filter_reviewer(auth_client): assert json['data'][0]['id'] == str(report.id) +def test_report_list_filter_verifier(auth_client): + user = auth_client.user + report = ReportFactory.create(verified_by=user) + ReportFactory.create() + + url = reverse('report-list') + + response = auth_client.get(url, data={'verifier': user.id}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(report.id) + + def test_report_list_filter_editable_owner(auth_client): user = auth_client.user report = ReportFactory.create(user=user) From 2f91ace1ce2bbebae724d0db0ceb173d0438fa76 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 13 Nov 2017 14:30:46 +0100 Subject: [PATCH 329/980] Replace custom ListFilter with django filter BaseInFilter --- timed/filters.py | 13 ------------- timed/tracking/filters.py | 6 +++--- 2 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 timed/filters.py diff --git a/timed/filters.py b/timed/filters.py deleted file mode 100644 index 28f99aafb..000000000 --- a/timed/filters.py +++ /dev/null @@ -1,13 +0,0 @@ -from django_filters import Filter - - -class ListFilter(Filter): - """List filter expecting list of values as comma separated values.""" - - def filter(self, qs, value): - if not value: - return qs - - self.lookup_expr = 'in' - values = value.split(',') - return super().filter(qs, values) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 3f93dc691..14e0e45e7 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -3,9 +3,9 @@ from functools import wraps from django.db.models import Q -from django_filters import DateFilter, Filter, FilterSet, NumberFilter +from django_filters import (BaseInFilter, DateFilter, Filter, FilterSet, + NumberFilter) -from timed.filters import ListFilter from timed.tracking import models @@ -90,7 +90,7 @@ class Meta: class ReportFilterSet(FilterSet): """Filter set for the reports endpoint.""" - id = ListFilter() + id = BaseInFilter() from_date = DateFilter(name='date', lookup_expr='gte') to_date = DateFilter(name='date', lookup_expr='lte') project = NumberFilter(name='task__project') From 8a96f18d52b76735a63648dde6e7f405413d547e Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:24 +0100 Subject: [PATCH 330/980] Pin coverage to latest version 4.4.2 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index e9bcfaa53..f628053ae 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,4 +1,4 @@ -coverage +coverage==4.4.2 factory-boy flake8 flake8-blind-except From 8e2d4341be2b2b6a613ecf5a5770919833216012 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:26 +0100 Subject: [PATCH 331/980] Pin factory-boy to latest version 2.9.2 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index f628053ae..90f8a3e7f 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,5 +1,5 @@ coverage==4.4.2 -factory-boy +factory-boy==2.9.2 flake8 flake8-blind-except flake8-debugger From a62dd50140fadd3b99de11eda3a693e84c9b1e1f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:27 +0100 Subject: [PATCH 332/980] Pin flake8 to latest version 3.5.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 90f8a3e7f..9457433d2 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,6 +1,6 @@ coverage==4.4.2 factory-boy==2.9.2 -flake8 +flake8==3.5.0 flake8-blind-except flake8-debugger flake8-deprecated From 1462f5f0600b8db9185763bf0af2aa7e6390372f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:28 +0100 Subject: [PATCH 333/980] Pin flake8-blind-except to latest version 0.1.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 9457433d2..203ea018f 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,7 +1,7 @@ coverage==4.4.2 factory-boy==2.9.2 flake8==3.5.0 -flake8-blind-except +flake8-blind-except==0.1.1 flake8-debugger flake8-deprecated flake8-docstrings From deaa5596380edfb3b71748253f752f3f6309f665 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:30 +0100 Subject: [PATCH 334/980] Pin flake8-debugger to latest version 3.0.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 203ea018f..a62253314 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -2,7 +2,7 @@ coverage==4.4.2 factory-boy==2.9.2 flake8==3.5.0 flake8-blind-except==0.1.1 -flake8-debugger +flake8-debugger==3.0.0 flake8-deprecated flake8-docstrings flake8-isort From 8ec0eed24413cd1c87ff277319b1cae4d93fc147 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:31 +0100 Subject: [PATCH 335/980] Pin flake8-deprecated to latest version 1.3 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index a62253314..951237ae8 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -3,7 +3,7 @@ factory-boy==2.9.2 flake8==3.5.0 flake8-blind-except==0.1.1 flake8-debugger==3.0.0 -flake8-deprecated +flake8-deprecated==1.3 flake8-docstrings flake8-isort flake8-quotes From 573682af70e77decb0f90ed2004adf557533fd42 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:33 +0100 Subject: [PATCH 336/980] Pin flake8-docstrings to latest version 1.1.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 951237ae8..38386acde 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -4,7 +4,7 @@ flake8==3.5.0 flake8-blind-except==0.1.1 flake8-debugger==3.0.0 flake8-deprecated==1.3 -flake8-docstrings +flake8-docstrings==1.1.0 flake8-isort flake8-quotes flake8-string-format From d826a517c2fca3bb218c74f36d8f5606bdd3738f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:34 +0100 Subject: [PATCH 337/980] Pin flake8-isort to latest version 2.2.2 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 38386acde..b2db9c07e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -5,7 +5,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.0.0 flake8-deprecated==1.3 flake8-docstrings==1.1.0 -flake8-isort +flake8-isort==2.2.2 flake8-quotes flake8-string-format ipdb From 64b0d8dc2d1ab2a92d5b424b228046e69523260f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:36 +0100 Subject: [PATCH 338/980] Pin flake8-quotes to latest version 0.12.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index b2db9c07e..27e0eaec7 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,7 +6,7 @@ flake8-debugger==3.0.0 flake8-deprecated==1.3 flake8-docstrings==1.1.0 flake8-isort==2.2.2 -flake8-quotes +flake8-quotes==0.12.0 flake8-string-format ipdb isort From 0fc1897695f7eead759ea8259c9a62a1dfd3ddf2 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:37 +0100 Subject: [PATCH 339/980] Pin flake8-string-format to latest version 0.2.3 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 27e0eaec7..18b2cd497 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -7,7 +7,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.1.0 flake8-isort==2.2.2 flake8-quotes==0.12.0 -flake8-string-format +flake8-string-format==0.2.3 ipdb isort pytest From f080253818726a71dbb500b493582294cc021cd2 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:38 +0100 Subject: [PATCH 340/980] Pin ipdb to latest version 0.10.3 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 18b2cd497..36380fca2 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -8,7 +8,7 @@ flake8-docstrings==1.1.0 flake8-isort==2.2.2 flake8-quotes==0.12.0 flake8-string-format==0.2.3 -ipdb +ipdb==0.10.3 isort pytest pytest-cov From de8fbb95da0263d768e6275523f7358e6202743e Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:40 +0100 Subject: [PATCH 341/980] Pin isort to latest version 4.2.15 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 36380fca2..f8f955854 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -9,7 +9,7 @@ flake8-isort==2.2.2 flake8-quotes==0.12.0 flake8-string-format==0.2.3 ipdb==0.10.3 -isort +isort==4.2.15 pytest pytest-cov pytest-mock==1.6.2 From 954ba1951af8ac8109163a2df1b514edef8ae1f9 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:41 +0100 Subject: [PATCH 342/980] Pin pytest to latest version 3.2.3 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index f8f955854..e362d60a8 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -10,7 +10,7 @@ flake8-quotes==0.12.0 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 -pytest +pytest==3.2.3 pytest-cov pytest-mock==1.6.2 # once newer version than 3.1.2 has been released From 8dad958fc3c702b25ec499ea2c28643ba37ce381 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:43 +0100 Subject: [PATCH 343/980] Pin pytest-cov to latest version 2.5.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index e362d60a8..2c1f554d4 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 pytest==3.2.3 -pytest-cov +pytest-cov==2.5.1 pytest-mock==1.6.2 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again From da99e7325bd095c4d1b883d4275e965bc16812de Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 13 Nov 2017 17:08:44 +0100 Subject: [PATCH 344/980] Update pytest-mock from 1.6.2 to 1.6.3 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 2c1f554d4..b4eb80cdf 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -12,7 +12,7 @@ ipdb==0.10.3 isort==4.2.15 pytest==3.2.3 pytest-cov==2.5.1 -pytest-mock==1.6.2 +pytest-mock==1.6.3 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af From 316b78ff7e8b3ac5c048cd41ea202fa59b3978ff Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 13 Nov 2017 17:37:00 +0100 Subject: [PATCH 345/980] Define all dependencies in requirements.txt files This is needed for pyup.io support. It was not really a good way to pin dependency in setup.py anyway. --- Makefile | 4 ++-- requirements.txt | 23 +++++++++++++++++++++++ setup.py | 46 ++++++++++++++++++++-------------------------- 3 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 requirements.txt diff --git a/Makefile b/Makefile index 4c87f16f7..7116d23b5 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,11 @@ help: install: ## Install production environment @pip install --upgrade pip - @pip install --upgrade --process-dependency-links . + @pip install --upgrade requirements.txt install-dev: ## Install development environment @pip install --upgrade pip - @pip install --upgrade --process-dependency-links -r dev_requirements.txt -e . + @pip install --upgrade -r requirements.txt -r dev_requirements.txt start: ## Start the development server @docker-compose start db diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..85eb59519 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +python-dateutil>=2.6,<2.7 +django>=1.11,<1.12 # pyup: >=1.11,<1.12 +django-auth-ldap==1.2.11 +django-filter==1.0.2 +django-multiselectfield==0.1.6 +djangorestframework>=3.6,<3.7 +djangorestframework-jwt==1.10.0 +psycopg2>=2.7,<2.8 +pytz==2017.2 +pyexcel-webio==0.1.2 +pyexcel-io==0.5.1 +django-excel==0.0.9 +pyexcel-ods3==0.5.0 +pyexcel-xlsx==0.5.0.1 +pyexcel-ezodf==0.3.3 +django-environ==0.4.3 +rest_condition==1.0.3 +django-money==0.11.4 +python-redmine==2.0.2 +# TODO: when following PR are released, change back to official release +# https://github.com/django-json-api/django-rest-framework-json-api/pull/376 +# https://github.com/django-json-api/django-rest-framework-json-api/pull/374 +git+https://github.com/adfinis-forks/django-rest-framework-json-api.git@timed_master diff --git a/setup.py b/setup.py index 8481fe1b8..141503915 100644 --- a/setup.py +++ b/setup.py @@ -42,32 +42,26 @@ def find_data(packages, extensions): description='Timetracking software', long_description=README_TEXT, install_requires=( - 'python-dateutil>=2.6,<2.7', - 'django>=1.11,<1.12', - 'django-auth-ldap==1.2.11', - 'django-filter==1.0.2', - 'django-multiselectfield==0.1.6', - 'djangorestframework>=3.6,<3.7', - 'djangorestframework-jsonapi==2.2.0adsy1', - 'djangorestframework-jwt==1.10.0', - 'psycopg2>=2.7,<2.8', - 'pytz==2017.2', - 'pyexcel-webio==0.1.2', - 'pyexcel-io==0.5.1', - 'django-excel==0.0.9', - 'pyexcel-ods3==0.5.0', - 'pyexcel-xlsx==0.5.0.1', - 'pyexcel-ezodf==0.3.3', - 'django-environ==0.4.3', - 'rest_condition==1.0.3', - 'django-money==0.11.4', - 'python-redmine==2.0.2', - ), - dependency_links=( - # TODO: when following PR are released, change back to official release - # https://github.com/django-json-api/django-rest-framework-json-api/pull/376 - # https://github.com/django-json-api/django-rest-framework-json-api/pull/374 - 'https://github.com/adfinis-forks/django-rest-framework-json-api/tarball/timed_master#egg=djangorestframework-jsonapi-2.2.0adsy1', # noqa: E501 + 'python-dateutil', + 'django>=1.11', + 'django-auth-ldap', + 'django-filter', + 'django-multiselectfield', + 'djangorestframework', + 'djangorestframework-jsonapi', + 'djangorestframework-jwt', + 'psycopg2' + 'pytz', + 'pyexcel-webio', + 'pyexcel-io', + 'django-excel', + 'pyexcel-ods3', + 'pyexcel-xlsx', + 'pyexcel-ezodf', + 'django-environ', + 'rest_condition', + 'django-money', + 'python-redmine', ), keywords='timetracking', url='https://adfinis-sygroup.ch/', From a7497688c1950beac47dd614854e8a9f5a749a28 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Nov 2017 16:53:35 +0100 Subject: [PATCH 346/980] Make `make install` work again --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7116d23b5..93fc27c26 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ help: install: ## Install production environment @pip install --upgrade pip - @pip install --upgrade requirements.txt + @pip install --upgrade -r requirements.txt install-dev: ## Install development environment @pip install --upgrade pip From d0d29925f094a1a79786eb6c1a37d43e174c8810 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 13 Nov 2017 15:32:35 +0100 Subject: [PATCH 347/980] Enforce that only superuser may change verified reports This protects changing of reports which may already be billed. --- timed/tracking/filters.py | 16 ++++++++------ timed/tracking/tests/test_report.py | 33 ++++++++++++++++++++++++----- timed/tracking/views.py | 21 +++++++++++------- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 14e0e45e7..97402ce90 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -122,9 +122,11 @@ def filter_editable(self, queryset, name, value): # only owner, reviewer or supervisor may change reports queryset = queryset.filter( - Q(user__supervisors=user) | - Q(task__project__reviewers=user) | - Q(user=user) + ( + Q(user__supervisors=user) | + Q(task__project__reviewers=user) | + Q(user=user) + ) & Q(verified_by__isnull=True) ) return queryset @@ -134,9 +136,11 @@ def filter_editable(self, queryset, name, value): return queryset.none() queryset = queryset.exclude( - Q(user__supervisors=user) | - Q(task__project__reviewers=user) | - Q(user=user) + ( + Q(user__supervisors=user) | + Q(task__project__reviewers=user) | + Q(user=user) + ) & Q(verified_by__isnull=True) ) return queryset diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 7770fe745..2bd4c22be 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -397,9 +397,9 @@ def test_report_update_bulk_verify_superuser(superadmin_client): assert report.verified_by == user -def test_report_update_bulk_reset_verify_reviewer(auth_client): +def test_report_update_bulk_verify_reviewer(auth_client): user = auth_client.user - report = ReportFactory.create(verified_by=user) + report = ReportFactory.create(user=user) report.task.project.reviewers.add(user) url = reverse('report-bulk') @@ -409,7 +409,7 @@ def test_report_update_bulk_reset_verify_reviewer(auth_client): 'type': 'report-bulks', 'id': None, 'attributes': { - 'verified': False + 'verified': True } } } @@ -419,6 +419,29 @@ def test_report_update_bulk_reset_verify_reviewer(auth_client): ) assert response.status_code == status.HTTP_204_NO_CONTENT + report.refresh_from_db() + assert report.verified_by == user + + +def test_report_update_bulk_reset_verify(superadmin_client): + user = superadmin_client.user + report = ReportFactory.create(verified_by=user) + + url = reverse('report-bulk') + + data = { + 'data': { + 'type': 'report-bulks', + 'id': None, + 'attributes': { + 'verified': False + } + } + } + + response = superadmin_client.post(url + '?editable=1', data) + assert response.status_code == status.HTTP_204_NO_CONTENT + report.refresh_from_db() assert report.verified_by_id is None @@ -677,7 +700,7 @@ def test_report_verify_other_user(superadmin_client): def test_report_reset_verified_by_reviewer(auth_client): - """Test that reviewer may reset verified by on report.""" + """Test that reviewer may not change verified report.""" user = auth_client.user report = ReportFactory.create(user=user, verified_by=user) report.task.project.reviewers.add(user) @@ -699,7 +722,7 @@ def test_report_reset_verified_by_reviewer(auth_client): url = reverse('report-detail', args=[report.id]) response = auth_client.patch(url, data) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_403_FORBIDDEN def test_report_delete(auth_client): diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 7584c0d87..f149b14d1 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -86,8 +86,12 @@ class ReportViewSet(ModelViewSet): serializer_class = serializers.ReportSerializer filter_class = filters.ReportFilterSet permission_classes = [ - # reviewer, supervisor or superuser may change but not delete reports - (C(IsSuperUser) | C(IsReviewer) | C(IsSupervisor)) & ~C(IsDeleteOnly) | + # superuser may edit all reports but not delete + C(IsSuperUser) & ~C(IsDeleteOnly) | + # reviewer and supervisor may change unverified reports + # but not delete them + (C(IsReviewer) | C(IsSupervisor)) & + C(IsUnverified) & ~C(IsDeleteOnly) | # owner may only change its own unverified reports C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) | # all authenticated users may read all reports @@ -186,6 +190,12 @@ def bulk(self, request): if value is not None and key != 'verified' } + editable = request.query_params.get('editable') + if not user.is_superuser and not editable: + raise exceptions.ParseError( + _('Editable filter needs to be set for bulk update') + ) + with transaction.atomic(): verified = serializer.validated_data.get('verified') if verified is not None: @@ -194,7 +204,7 @@ def bulk(self, request): reviewer_id = request.query_params.get('reviewer') if not user.is_superuser and str(reviewer_id) != str(user.id): raise exceptions.ParseError( - _('You do not have the permission to verify reports') + _('Reviewer filter needs to be set to verifying user') ) verified_by = verified and user or None @@ -204,11 +214,6 @@ def bulk(self, request): # update all other fields if fields: - if not request.query_params.get('editable'): - raise exceptions.ParseError( - _('Editable filter needs to be set for bulk update') - ) - queryset.update(**fields) return Response(status=status.HTTP_204_NO_CONTENT) From 1d4d4e2f42024f9f7f3a17ec094f0cc4494a6738 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Nov 2017 17:06:12 +0100 Subject: [PATCH 348/980] Extract editable query into its own method This removed duplicated code --- timed/tracking/filters.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 97402ce90..5f72a1b95 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -115,19 +115,20 @@ def filter_editable(self, queryset, name, value): """ user = self.request.user + def get_editable_query(): + return ( + Q(user__supervisors=user) | + Q(task__project__reviewers=user) | + Q(user=user) + ) & Q(verified_by__isnull=True) + if value: # editable if user.is_superuser: # superuser may edit all reports return queryset - # only owner, reviewer or supervisor may change reports - queryset = queryset.filter( - ( - Q(user__supervisors=user) | - Q(task__project__reviewers=user) | - Q(user=user) - ) & Q(verified_by__isnull=True) - ) + # only owner, reviewer or supervisor may change unverified reports + queryset = queryset.filter(get_editable_query()) return queryset else: # not editable @@ -135,14 +136,7 @@ def filter_editable(self, queryset, name, value): # no reports which are not editable return queryset.none() - queryset = queryset.exclude( - ( - Q(user__supervisors=user) | - Q(task__project__reviewers=user) | - Q(user=user) - ) & Q(verified_by__isnull=True) - ) - + queryset = queryset.exclude(get_editable_query()) return queryset def filter_cost_center(self, queryset, name, value): From 648cc402b7d02538845851388925bee48ec6291a Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:07:49 +0100 Subject: [PATCH 349/980] Update django-auth-ldap from 1.2.11 to 1.2.16 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..9d384dd4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ python-dateutil>=2.6,<2.7 django>=1.11,<1.12 # pyup: >=1.11,<1.12 -django-auth-ldap==1.2.11 +django-auth-ldap==1.2.16 django-filter==1.0.2 django-multiselectfield==0.1.6 djangorestframework>=3.6,<3.7 From 21c420c365ae5d055aa020b77a10777a1c26f85c Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:07:56 +0100 Subject: [PATCH 350/980] Update django-multiselectfield from 0.1.6 to 0.1.8 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..a75250217 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ python-dateutil>=2.6,<2.7 django>=1.11,<1.12 # pyup: >=1.11,<1.12 django-auth-ldap==1.2.11 django-filter==1.0.2 -django-multiselectfield==0.1.6 +django-multiselectfield==0.1.8 djangorestframework>=3.6,<3.7 djangorestframework-jwt==1.10.0 psycopg2>=2.7,<2.8 From 919ceb485b8fca935d4fc5f15a5142be0cefa346 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:08:07 +0100 Subject: [PATCH 351/980] Update djangorestframework-jwt from 1.10.0 to 1.11.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..6460212b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ django-auth-ldap==1.2.11 django-filter==1.0.2 django-multiselectfield==0.1.6 djangorestframework>=3.6,<3.7 -djangorestframework-jwt==1.10.0 +djangorestframework-jwt==1.11.0 psycopg2>=2.7,<2.8 pytz==2017.2 pyexcel-webio==0.1.2 From 2a07daad323088497aeeca5c31c740030e1af455 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:08:11 +0100 Subject: [PATCH 352/980] Update pytz from 2017.2 to 2017.3 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..7940bce73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.6 djangorestframework>=3.6,<3.7 djangorestframework-jwt==1.10.0 psycopg2>=2.7,<2.8 -pytz==2017.2 +pytz==2017.3 pyexcel-webio==0.1.2 pyexcel-io==0.5.1 django-excel==0.0.9 From af8c78caa33b1d6b770f65e6b5bc0a10cce97961 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:08:18 +0100 Subject: [PATCH 353/980] Update pyexcel-io from 0.5.1 to 0.5.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..8d07550e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ djangorestframework-jwt==1.10.0 psycopg2>=2.7,<2.8 pytz==2017.2 pyexcel-webio==0.1.2 -pyexcel-io==0.5.1 +pyexcel-io==0.5.4 django-excel==0.0.9 pyexcel-ods3==0.5.0 pyexcel-xlsx==0.5.0.1 From b5375287bf895646b1c47e0e3b2573d71833b63a Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:08:26 +0100 Subject: [PATCH 354/980] Update pyexcel-xlsx from 0.5.0.1 to 0.5.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..2872b8d0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyexcel-webio==0.1.2 pyexcel-io==0.5.1 django-excel==0.0.9 pyexcel-ods3==0.5.0 -pyexcel-xlsx==0.5.0.1 +pyexcel-xlsx==0.5.4 pyexcel-ezodf==0.3.3 django-environ==0.4.3 rest_condition==1.0.3 From e0994b906761ba7ebff65a075ce20f251a7f6a74 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:08:33 +0100 Subject: [PATCH 355/980] Update django-environ from 0.4.3 to 0.4.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..0b2eca0f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ django-excel==0.0.9 pyexcel-ods3==0.5.0 pyexcel-xlsx==0.5.0.1 pyexcel-ezodf==0.3.3 -django-environ==0.4.3 +django-environ==0.4.4 rest_condition==1.0.3 django-money==0.11.4 python-redmine==2.0.2 From 79def1ceb8f6c62e73cebc037435476024a7fdbb Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 17:08:37 +0100 Subject: [PATCH 356/980] Update django-money from 0.11.4 to 0.12 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85eb59519..7326ce869 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ pyexcel-xlsx==0.5.0.1 pyexcel-ezodf==0.3.3 django-environ==0.4.3 rest_condition==1.0.3 -django-money==0.11.4 +django-money==0.12 python-redmine==2.0.2 # TODO: when following PR are released, change back to official release # https://github.com/django-json-api/django-rest-framework-json-api/pull/376 From 4158582e685b1bfe8baf4859a75e8cf42259a26a Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 14 Nov 2017 21:46:11 +0100 Subject: [PATCH 357/980] Update pytest from 3.2.3 to 3.2.4 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index b4eb80cdf..43c23a9b7 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -10,7 +10,7 @@ flake8-quotes==0.12.0 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 -pytest==3.2.3 +pytest==3.2.4 pytest-cov==2.5.1 pytest-mock==1.6.3 # once newer version than 3.1.2 has been released From 6efbac16e02bca0426c467947a46432bbb44fabe Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 15 Nov 2017 09:13:05 +0100 Subject: [PATCH 358/980] Update django-filter from 1.0.2 to 1.1.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fa555858d..74ecbcfb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ python-dateutil>=2.6,<2.7 django>=1.11,<1.12 # pyup: >=1.11,<1.12 django-auth-ldap==1.2.16 -django-filter==1.0.2 +django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework>=3.6,<3.7 djangorestframework-jwt==1.10.0 From ea749e60b0a37878a54d0c91d9a5d9d12c071a13 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 15 Nov 2017 09:30:30 +0100 Subject: [PATCH 359/980] Update pyexcel-ods3 from 0.5.0 to 0.5.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d83c831a6..ebc1c6c6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ pytz==2017.2 pyexcel-webio==0.1.2 pyexcel-io==0.5.1 django-excel==0.0.9 -pyexcel-ods3==0.5.0 +pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.4 pyexcel-ezodf==0.3.3 django-environ==0.4.4 From 90cdc2c3c820a4a0a7bde0493d14ce27820e1098 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 15 Nov 2017 09:34:23 +0100 Subject: [PATCH 360/980] Update pyexcel-ezodf from 0.3.3 to 0.3.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8eb719171..23fd35644 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyexcel-io==0.5.4 django-excel==0.0.9 pyexcel-ods3==0.5.0 pyexcel-xlsx==0.5.4 -pyexcel-ezodf==0.3.3 +pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 django-money==0.12 From 06aba1848bfd122a1dded3d45a01477d1ab54474 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 15 Nov 2017 09:43:32 +0100 Subject: [PATCH 361/980] Update pyexcel-webio from 0.1.2 to 0.1.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 31b748038..93974e0b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework>=3.6,<3.7 djangorestframework-jwt==1.11.0 psycopg2>=2.7,<2.8 pytz==2017.3 -pyexcel-webio==0.1.2 +pyexcel-webio==0.1.4 pyexcel-io==0.5.4 django-excel==0.0.9 pyexcel-ods3==0.5.2 From 072965b3dbf327b477b647554699903dbbfb90f3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Nov 2017 09:52:46 +0100 Subject: [PATCH 362/980] Pin rest framework to 3.6 series --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 93974e0b2..6d929e132 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django>=1.11,<1.12 # pyup: >=1.11,<1.12 django-auth-ldap==1.2.16 django-filter==1.1.0 django-multiselectfield==0.1.8 -djangorestframework>=3.6,<3.7 +djangorestframework==3.6.4 # pyup: >=3.6,<3.7 djangorestframework-jwt==1.11.0 psycopg2>=2.7,<2.8 pytz==2017.3 From e8ba42a9f452e641aa310608aefd24563d47cc04 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 15 Nov 2017 12:36:18 +0100 Subject: [PATCH 363/980] Update pytest from 3.2.4 to 3.2.5 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 43c23a9b7..6602785b1 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -10,7 +10,7 @@ flake8-quotes==0.12.0 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 -pytest==3.2.4 +pytest==3.2.5 pytest-cov==2.5.1 pytest-mock==1.6.3 # once newer version than 3.1.2 has been released From b668f759489402f53e824453f0bacf50e89cc532 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Nov 2017 14:35:46 +0100 Subject: [PATCH 364/980] Replace filter name with field_name Fixes deprecation warning --- timed/employment/filters.py | 30 +++++++++++++++--------------- timed/projects/filters.py | 6 +++--- timed/tracking/filters.py | 26 +++++++++++++------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index c2741f06a..ec74da5f1 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -26,9 +26,9 @@ def filter(self, qs, value): class PublicHolidayFilterSet(FilterSet): """Filter set for the public holidays endpoint.""" - year = YearFilter(name='date') - from_date = DateFilter(name='date', lookup_expr='gte') - to_date = DateFilter(name='date', lookup_expr='lte') + year = YearFilter(field_name='date') + from_date = DateFilter(field_name='date', lookup_expr='gte') + to_date = DateFilter(field_name='date', lookup_expr='lte') class Meta: """Meta information for the public holiday filter set.""" @@ -38,7 +38,7 @@ class Meta: class AbsenceTypeFilterSet(FilterSet): - fill_worktime = NumberFilter(name='fill_worktime') + fill_worktime = NumberFilter(field_name='fill_worktime') class Meta: """Meta information for the public holiday filter set.""" @@ -48,8 +48,8 @@ class Meta: class UserFilterSet(FilterSet): - active = NumberFilter(name='is_active') - supervisor = NumberFilter(name='supervisors') + active = NumberFilter(field_name='is_active') + supervisor = NumberFilter(field_name='supervisors') class Meta: model = models.User @@ -77,9 +77,9 @@ class Meta: class OvertimeCreditFilterSet(FilterSet): - year = YearFilter(name='date') - from_date = DateFilter(name='date', lookup_expr='gte') - to_date = DateFilter(name='date', lookup_expr='lte') + year = YearFilter(field_name='date') + from_date = DateFilter(field_name='date', lookup_expr='gte') + to_date = DateFilter(field_name='date', lookup_expr='lte') class Meta: model = models.OvertimeCredit @@ -87,9 +87,9 @@ class Meta: class AbsenceCreditFilterSet(FilterSet): - year = YearFilter(name='date') - from_date = DateFilter(name='date', lookup_expr='gte') - to_date = DateFilter(name='date', lookup_expr='lte') + year = YearFilter(field_name='date') + from_date = DateFilter(field_name='date', lookup_expr='gte') + to_date = DateFilter(field_name='date', lookup_expr='lte') class Meta: model = models.AbsenceCredit @@ -99,8 +99,8 @@ class Meta: class WorktimeBalanceFilterSet(FilterSet): - user = NumberFilter(name='id') - supervisor = NumberFilter(name='supervisors') + user = NumberFilter(field_name='id') + supervisor = NumberFilter(field_name='supervisors') # additional filters analyzed in WorktimeBalanceView # date = DateFilter() # last_reported_date = NumberFilter() @@ -111,7 +111,7 @@ class Meta: class AbsenceBalanceFilterSet(FilterSet): - absence_type = NumberFilter(name='id') + absence_type = NumberFilter(field_name='id') class Meta: model = models.AbsenceType diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 0b9335079..f09a62e32 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -25,8 +25,8 @@ class Meta: class ProjectFilterSet(FilterSet): """Filter set for the projects endpoint.""" - archived = NumberFilter(name='archived') - reviewer = NumberFilter(name='reviewers') + archived = NumberFilter(field_name='archived') + reviewer = NumberFilter(field_name='reviewers') class Meta: """Meta information for the project filter set.""" @@ -82,7 +82,7 @@ class TaskFilterSet(FilterSet): """Filter set for the tasks endpoint.""" my_most_frequent = MyMostFrequentTaskFilter() - archived = NumberFilter(name='archived') + archived = NumberFilter(field_name='archived') class Meta: """Meta information for the task filter set.""" diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 5f72a1b95..5191277da 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -58,7 +58,7 @@ class ActivityFilterSet(FilterSet): """Filter set for the activities endpoint.""" active = ActivityActiveFilter() - day = DateFilter(name='date') + day = DateFilter(field_name='date') class Meta: """Meta information for the activity filter set.""" @@ -91,20 +91,20 @@ class ReportFilterSet(FilterSet): """Filter set for the reports endpoint.""" id = BaseInFilter() - from_date = DateFilter(name='date', lookup_expr='gte') - to_date = DateFilter(name='date', lookup_expr='lte') - project = NumberFilter(name='task__project') - customer = NumberFilter(name='task__project__customer') - review = NumberFilter(name='review') + from_date = DateFilter(field_name='date', lookup_expr='gte') + to_date = DateFilter(field_name='date', lookup_expr='lte') + project = NumberFilter(field_name='task__project') + customer = NumberFilter(field_name='task__project__customer') + review = NumberFilter(field_name='review') editable = NumberFilter(method='filter_editable') - not_billable = NumberFilter(name='not_billable') + not_billable = NumberFilter(field_name='not_billable') verified = NumberFilter( name='verified_by_id', lookup_expr='isnull', exclude=True ) - reviewer = NumberFilter(name='task__project__reviewers') - verifier = NumberFilter(name='verified_by') - billing_type = NumberFilter(name='task__project__billing_type') - user = NumberFilter(name='user_id') + reviewer = NumberFilter(field_name='task__project__reviewers') + verifier = NumberFilter(field_name='verified_by') + billing_type = NumberFilter(field_name='task__project__billing_type') + user = NumberFilter(field_name='user_id') cost_center = NumberFilter(method='filter_cost_center') def filter_editable(self, queryset, name, value): @@ -174,8 +174,8 @@ class Meta: class AbsenceFilterSet(FilterSet): """Filter set for the absences endpoint.""" - from_date = DateFilter(name='date', lookup_expr='gte') - to_date = DateFilter(name='date', lookup_expr='lte') + from_date = DateFilter(field_name='date', lookup_expr='gte') + to_date = DateFilter(field_name='date', lookup_expr='lte') class Meta: """Meta information for the absence filter set.""" From dbd0c9e1f34b75bf5cb4d7c305e0deeacd3915bc Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Nov 2017 14:35:54 +0100 Subject: [PATCH 365/980] Use rest framework filters --- timed/employment/filters.py | 3 ++- timed/projects/filters.py | 4 ++-- timed/tracking/filters.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index ec74da5f1..618899508 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -2,7 +2,8 @@ from django.db.models import Value from django.db.models.functions import Coalesce -from django_filters import DateFilter, Filter, FilterSet, NumberFilter +from django_filters.rest_framework import (DateFilter, Filter, FilterSet, + NumberFilter) from timed.employment import models diff --git a/timed/projects/filters.py b/timed/projects/filters.py index f09a62e32..0c9464541 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -2,7 +2,7 @@ from datetime import date, timedelta from django.db.models import Count -from django_filters import Filter, FilterSet, NumberFilter +from django_filters.rest_framework import Filter, FilterSet, NumberFilter from timed.projects import models @@ -10,7 +10,7 @@ class CustomerFilterSet(FilterSet): """Filter set for the customers endpoint.""" - archived = NumberFilter(name='archived') + archived = NumberFilter(field_name='archived') class Meta: """Meta information for the customer filter set.""" diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 5191277da..0fe7ccae5 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -3,8 +3,8 @@ from functools import wraps from django.db.models import Q -from django_filters import (BaseInFilter, DateFilter, Filter, FilterSet, - NumberFilter) +from django_filters.rest_framework import (BaseInFilter, DateFilter, Filter, + FilterSet, NumberFilter) from timed.tracking import models From def9bae344d561afd23e3356a88a09f89ade3aa0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 16 Nov 2017 11:45:01 +0100 Subject: [PATCH 366/980] Remove obsolete csrf middleware This was only needed as session authentication was first authenticated backend. This has been fixed a while ago but middleware was not removed. --- timed/middleware.py | 9 --------- timed/settings.py | 3 +-- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 timed/middleware.py diff --git a/timed/middleware.py b/timed/middleware.py deleted file mode 100644 index 833003367..000000000 --- a/timed/middleware.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Custom middlewares.""" - - -class DisableCSRFMiddleware(object): - """Middleware to disable CSRF.""" - - def process_request(self, request): - """Process request and set property to true.""" - setattr(request, '_dont_enforce_csrf_checks', True) diff --git a/timed/settings.py b/timed/settings.py index 2cd9c167b..f3629ceea 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -71,7 +71,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'timed.subscription', ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -80,7 +80,6 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'timed.middleware.DisableCSRFMiddleware', ] ROOT_URLCONF = 'timed.urls' From f809d4740636157de842eea8de8d02a11617ef1e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 16 Nov 2017 12:24:32 +0100 Subject: [PATCH 367/980] Replace deprecated name in YearFilter with field_name --- timed/employment/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 618899508..9c73df815 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -20,7 +20,7 @@ def filter(self, qs, value): :rtype: QuerySet """ return qs.filter(**{ - '%s__year' % self.name: value + '%s__year' % self.field_name: value }) From 3a00097d8a3f71d7a4689999ea2a2f7b54bd99c6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 16 Nov 2017 15:24:48 +0100 Subject: [PATCH 368/980] Remove obsolete authentication through token query param Frontend doesn't require this anymore for downloads. As it is a security concern that token end up in log files it is better to remove it as not needed anymore. --- timed/authentication.py | 8 -------- timed/settings.py | 1 - 2 files changed, 9 deletions(-) delete mode 100644 timed/authentication.py diff --git a/timed/authentication.py b/timed/authentication.py deleted file mode 100644 index 12c903d12..000000000 --- a/timed/authentication.py +++ /dev/null @@ -1,8 +0,0 @@ -from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication - - -class JSONWebTokenAuthenticationQueryParam(BaseJSONWebTokenAuthentication): - """Allows json web token to be passed on as GET parameter.""" - - def get_jwt_value(self, request): - return request.query_params.get('jwt') diff --git a/timed/settings.py b/timed/settings.py index f3629ceea..152ae33f5 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -164,7 +164,6 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ), 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', - 'timed.authentication.JSONWebTokenAuthenticationQueryParam', 'rest_framework.authentication.SessionAuthentication', ), 'DEFAULT_METADATA_CLASS': From cd9f80ad2bd2a9689d57ba32c4ffa316fd271deb Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 16 Nov 2017 15:47:37 +0100 Subject: [PATCH 369/980] Update badges --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dca3c7b01..c5a4ba2f2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # Timed Backend -[![Build Status](https://img.shields.io/travis/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://travis-ci.org/adfinis-sygroup/timed-backend) -[![Coverage](https://img.shields.io/codecov/c/github/adfinis-sygroup/timed-backend/master.svg?style=flat-square)](https://codecov.io/gh/adfinis-sygroup/timed-backend) -[![License](https://img.shields.io/github/license/adfinis-sygroup/timed-backend.svg?style=flat-square)](LICENSE) + +[![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) +[![Codecov](https://codecov.io/gh/adfinis-sygroup/timed-backend/branch/master/graph/badge.svg)](https://codecov.io/gh/adfinis-sygroup/timed-backend) +[![Pyup](https://pyup.io/repos/github/adfinis-sygroup/timed-backend/shield.svg)](https://pyup.io/account/repos/github/adfinis-sygroup/timed-backend/) +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) Timed timetracking software REST API built with Django From 42989c9e62ae1578f04c873433b7b28ba7397897 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 20 Nov 2017 11:18:26 +0100 Subject: [PATCH 370/980] Update django-money from 0.12 to 0.12.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6d929e132..403f35bcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ pyexcel-xlsx==0.5.4 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 -django-money==0.12 +django-money==0.12.1 python-redmine==2.0.2 # TODO: when following PR are released, change back to official release # https://github.com/django-json-api/django-rest-framework-json-api/pull/376 From 92e67dcf2dcbd9ff00d8f3515da8343f1bfa9aa9 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 20 Nov 2017 17:33:28 +0100 Subject: [PATCH 371/980] Update django-auth-ldap from 1.2.16 to 1.3.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 403f35bcd..b11836396 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ python-dateutil>=2.6,<2.7 django>=1.11,<1.12 # pyup: >=1.11,<1.12 -django-auth-ldap==1.2.16 +django-auth-ldap==1.3.0 django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework==3.6.4 # pyup: >=3.6,<3.7 From 0738f875383930bf92b047baea5c7c27ccc22811 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 27 Nov 2017 08:58:25 +0100 Subject: [PATCH 372/980] Catch all redmine errors This also logs Forbidden errors --- timed/redmine/management/commands/redmine_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 6fa03b042..3bf8e2648 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -80,7 +80,7 @@ def handle(self, *args, **options): 'value': total_hours }] issue.save() - except redminelib.exceptions.ResourceNotFoundError: + except redminelib.exceptions.BaseRedmineError: sys.stderr.write( 'Project {0} has an invalid Redmine ' 'issue {1} assigned. Skipping'.format( From 03f34705ccce18d1238f596544edf452c5c8b162 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 27 Nov 2017 23:01:36 +0100 Subject: [PATCH 373/980] Update pytest from 3.2.5 to 3.3.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 6602785b1..ecd38f49e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -10,7 +10,7 @@ flake8-quotes==0.12.0 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 -pytest==3.2.5 +pytest==3.3.0 pytest-cov==2.5.1 pytest-mock==1.6.3 # once newer version than 3.1.2 has been released From f1b4c01930ade17991cbcd279f6fbb4e79cd3bce Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 27 Nov 2017 17:40:39 +0100 Subject: [PATCH 374/980] Add test to authenticate with ldap backend --- dev_requirements.txt | 2 ++ pytest.ini | 4 ++++ timed/conftest.py | 31 +++++++++++++++++++++++++++++ timed/employment/tests/test_user.py | 9 +++++++++ timed/settings.py | 6 +++--- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index ecd38f49e..fe6007d1d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -10,8 +10,10 @@ flake8-quotes==0.12.0 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 +mockldap==0.3.0 pytest==3.3.0 pytest-cov==2.5.1 +pytest-env==0.6.2 pytest-mock==1.6.3 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again diff --git a/pytest.ini b/pytest.ini index 283ba4b6f..73d9a5b1e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,7 @@ [pytest] DJANGO_SETTINGS_MODULE=timed.settings addopts = --reuse-db +env = + DJANGO_AUTH_LDAP_ENABLED=True + DJANGO_AUTH_LDAP_SERVER_URI=ldap://127.0.0.1 + DJANGO_AUTH_LDAP_USER_DN_TEMPLATE=uid=%(user)s,ou=people,o=test diff --git a/timed/conftest.py b/timed/conftest.py index 894a05366..55f38efd7 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -1,9 +1,40 @@ +import mockldap import pytest from django.contrib.auth import get_user_model from timed.tests.client import JSONAPIClient +@pytest.fixture(autouse=True, scope='session') +def ldap_directory(): + top = ('o=test', {'o': 'test'}) + people = ('ou=people,o=test', {'ou': 'people'}) + groups = ('ou=groups,o=test', {'ou': 'groups'}) + ldapuser = ( + 'uid=ldapuser,ou=people,o=test', { + 'uid': ['ldapuser'], + 'objectClass': [ + 'person', 'organizationalPerson', + 'inetOrgPerson', 'posixAccount' + ], + 'userPassword': ['Test1234!'], + 'uidNumber': ['1000'], + 'gidNumber': ['1000'], + 'givenName': ['givenName'], + 'mail': ['ldapuser@example.net'], + 'sn': ['LdapUser'] + } + ) + + directory = dict([top, people, groups, ldapuser]) + mock = mockldap.MockLdap(directory) + mock.start() + + yield + + mock.stop() + + @pytest.fixture def client(db): return JSONAPIClient() diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 0176f1f1e..5a9b2940b 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -1,6 +1,7 @@ from datetime import date, timedelta import pytest +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from rest_framework import status @@ -22,6 +23,14 @@ def test_user_update_unauthenticated(client): assert response.status_code == status.HTTP_401_UNAUTHORIZED +def test_user_login_ldap(client): + client.login('ldapuser', 'Test1234!') + user = get_user_model().objects.get(username='ldapuser') + assert user.first_name == 'givenName' + assert user.last_name == 'LdapUser' + assert user.email == 'ldapuser@example.net' + + def test_user_list(auth_client, django_assert_num_queries): UserFactory.create_batch(2) diff --git a/timed/settings.py b/timed/settings.py index 152ae33f5..642ba0766 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -190,7 +190,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ] AUTH_LDAP_ENABLED = env.bool('DJANGO_AUTH_LDAP_ENABLED', default=False) -if AUTH_LDAP_ENABLED: # pragma: todo cover +if AUTH_LDAP_ENABLED: AUTH_LDAP_USER_ATTR_MAP = env.dict( 'DJANGO_AUTH_LDAP_USER_ATTR_MAP', default={ @@ -201,8 +201,8 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ) AUTH_LDAP_SERVER_URI = env.str('DJANGO_AUTH_LDAP_SERVER_URI') - AUTH_LDAP_BIND_DN = env.str('DJANGO_AUTH_LDAP_BIND_DN') - AUTH_LDAP_PASSWORD = env.str('DJANGO_AUTH_LDAP_PASSWORD') + AUTH_LDAP_BIND_DN = env.str('DJANGO_AUTH_LDAP_BIND_DN', default='') + AUTH_LDAP_PASSWORD = env.str('DJANGO_AUTH_LDAP_PASSWORD', default='') AUTH_LDAP_USER_DN_TEMPLATE = env.str('DJANGO_AUTH_LDAP_USER_DN_TEMPLATE') AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend') From 716db7fb137670567fd5ecd367b49c157baa1838 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 29 Nov 2017 15:41:52 +0100 Subject: [PATCH 375/980] Move from DefaultRouter to SimpleRouter DefaultRouter has a feature to enable root view. This only works though if a single DefaultRouter is used. This is not desired in our case\ so root view was not complete. To not confuse it is therefore better to move to SimpleRouter. --- timed/employment/urls.py | 4 ++-- timed/projects/urls.py | 4 ++-- timed/reports/urls.py | 4 ++-- timed/tracking/urls.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/timed/employment/urls.py b/timed/employment/urls.py index c2b3562e4..4225545c4 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -1,11 +1,11 @@ """URL to view mapping for the employment app.""" from django.conf import settings -from rest_framework.routers import DefaultRouter +from rest_framework.routers import SimpleRouter from timed.employment import views -r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) +r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) r.register(r'users', views.UserViewSet, 'user') r.register(r'employments', views.EmploymentViewSet, 'employment') diff --git a/timed/projects/urls.py b/timed/projects/urls.py index 370d8b1d6..50eeef3ce 100644 --- a/timed/projects/urls.py +++ b/timed/projects/urls.py @@ -1,11 +1,11 @@ """URL to view mapping for the projects app.""" from django.conf import settings -from rest_framework.routers import DefaultRouter +from rest_framework.routers import SimpleRouter from timed.projects import views -r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) +r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) r.register(r'projects', views.ProjectViewSet, 'project') r.register(r'customers', views.CustomerViewSet, 'customer') diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 0d1af1509..8330215aa 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -1,9 +1,9 @@ from django.conf import settings -from rest_framework.routers import DefaultRouter +from rest_framework.routers import SimpleRouter from . import views -r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) +r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) r.register(r'work-reports', views.WorkReportViewSet, 'work-report') r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') diff --git a/timed/tracking/urls.py b/timed/tracking/urls.py index 2e3e5be60..563d1d936 100644 --- a/timed/tracking/urls.py +++ b/timed/tracking/urls.py @@ -1,11 +1,11 @@ """URL to view mapping for the tracking app.""" from django.conf import settings -from rest_framework.routers import DefaultRouter +from rest_framework.routers import SimpleRouter from timed.tracking import views -r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) +r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) r.register(r'activities', views.ActivityViewSet, 'activity') r.register(r'activity-blocks', views.ActivityBlockViewSet, 'activity-block') From 82446d9ae0247a6df754cd257d1f47fe99d136cd Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 2 Dec 2017 06:03:49 +0100 Subject: [PATCH 376/980] Update flake8-quotes from 0.12.0 to 0.12.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index fe6007d1d..0a9eaf955 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,7 +6,7 @@ flake8-debugger==3.0.0 flake8-deprecated==1.3 flake8-docstrings==1.1.0 flake8-isort==2.2.2 -flake8-quotes==0.12.0 +flake8-quotes==0.12.1 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 From e2a93c42e56311fb453a0488f27a40cba77152ef Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 6 Dec 2017 01:46:54 +0100 Subject: [PATCH 377/980] Update pytest from 3.3.0 to 3.3.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 0a9eaf955..492006a91 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 mockldap==0.3.0 -pytest==3.3.0 +pytest==3.3.1 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.6.3 From a62def82e42b3d7b64d712621d60a42d5c6b476e Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Wed, 6 Dec 2017 16:21:50 +0100 Subject: [PATCH 378/980] Add reviewers to project serializer --- timed/projects/serializers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 58f83e5f9..db6e96415 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -56,7 +56,8 @@ class ProjectSerializer(ModelSerializer): included_serializers = { 'customer': 'timed.projects.serializers.CustomerSerializer', 'billing_type': 'timed.projects.serializers.BillingTypeSerializer', - 'cost_center': 'timed.projects.serializers.CostCenterSerializer' + 'cost_center': 'timed.projects.serializers.CostCenterSerializer', + 'reviewers': 'timed.employment.serializers.UserSerializer', } def get_root_meta(self, resource, many): @@ -82,7 +83,8 @@ class Meta: 'archived', 'customer', 'billing_type', - 'cost_center' + 'cost_center', + 'reviewers' ] From dea11f5e4f1623fa649014d66c13be0cfc65101a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 8 Dec 2017 12:12:03 +0100 Subject: [PATCH 379/980] Reduce number of queries to fetch reviewers --- .flake8 | 1 + timed/mixins.py | 25 ++++++++++++++----- timed/projects/tests/test_project.py | 20 +++++++++++++++ timed/projects/views.py | 17 +++++++------ timed/reports/tests/test_project_statistic.py | 2 +- timed/reports/tests/test_task_statistic.py | 2 +- timed/reports/views.py | 8 ++++++ 7 files changed, 60 insertions(+), 15 deletions(-) diff --git a/.flake8 b/.flake8 index 43785de01..5f7b1bb96 100644 --- a/.flake8 +++ b/.flake8 @@ -21,6 +21,7 @@ ignore = # Missing docstring in __init__ D107 +doctests = True exclude = manage.py, Makefile, diff --git a/timed/mixins.py b/timed/mixins.py index 63dcf617b..72ebb84d3 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -18,6 +18,16 @@ class AggregateQuerysetMixin(object): Mixin expects the id to be the same key as the resource related field defined in the serializer. + + To reduce number of queries `prefetch_related_for_field` can be defined + to prefetch related data per field like the following: + >>> from rest_framework.viewsets import ReadOnlyModelViewSet + ... class MyView(ReadOnlyModelViewSet, AggregateQuerysetMixin): + ... # ... + ... prefetch_related_for_field = { + ... 'field_name': ['field_name_prefetch'] + ... } + ... # ... """ def _is_related_field(self, val): @@ -50,12 +60,15 @@ def get_serializer(self, data, *args, **kwargs): obj_ids = data.values_list(source, flat=True) else: obj_ids = [data[0][source]] - objects = { - obj.id: obj - for obj in value.model.objects.filter( - id__in=obj_ids - ).select_related() - } + + qs = value.model.objects.filter(id__in=obj_ids) + qs = qs.select_related() + if hasattr(self, 'prefetch_related_for_field'): + qs = qs.prefetch_related( + *self.prefetch_related_for_field.get(source, []) + ) + + objects = {obj.id: obj for obj in qs} prefetch_per_field[source] = objects # enhance entry dicts with model instances diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 95ee6e9f1..26f8fd78d 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -4,7 +4,9 @@ from django.core.urlresolvers import reverse from rest_framework import status +from timed.employment.factories import UserFactory from timed.projects.factories import ProjectFactory, TaskFactory +from timed.projects.serializers import ProjectSerializer from timed.tracking.factories import ReportFactory @@ -22,6 +24,24 @@ def test_project_list_not_archived(auth_client): assert json['data'][0]['id'] == str(project.id) +def test_project_list_include(auth_client, django_assert_num_queries): + project = ProjectFactory.create() + users = UserFactory.create_batch(2) + project.reviewers.add(*users) + + url = reverse('project-list') + + with django_assert_num_queries(6): + response = auth_client.get(url, data={ + 'include': ','.join(ProjectSerializer.included_serializers.keys()) + }) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(project.id) + + def test_project_detail_no_auth(client): project = ProjectFactory.create() diff --git a/timed/projects/views.py b/timed/projects/views.py index f8b514587..233f0f54f 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,6 +1,7 @@ """Viewsets for the projects app.""" from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework_json_api.views import PrefetchForIncludesHelperMixin from timed.projects import filters, models, serializers @@ -39,21 +40,23 @@ def get_queryset(self): return models.CostCenter.objects.all() -class ProjectViewSet(ReadOnlyModelViewSet): +class ProjectViewSet(PrefetchForIncludesHelperMixin, ReadOnlyModelViewSet): """Project view set.""" serializer_class = serializers.ProjectSerializer filter_class = filters.ProjectFilterSet ordering_fields = ('customer__name', 'name',) ordering = 'name' + queryset = models.Project.objects.all() - def get_queryset(self): - """Prefetch related data. + prefetch_for_includes = { + '__all__': ['reviewers'], + 'reviewers': ['reviewers__supervisors'], + } - :return: The projects - :rtype: QuerySet - """ - return models.Project.objects.select_related( + def get_queryset(self): + queryset = super().get_queryset() + return queryset.select_related( 'customer', 'billing_type', 'cost_center' ) diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index f8ad7136d..e312eec71 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -11,7 +11,7 @@ def test_project_statistic_list(auth_client, django_assert_num_queries): report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse('project-statistic-list') - with django_assert_num_queries(4): + with django_assert_num_queries(5): result = auth_client.get(url, data={ 'ordering': 'duration', 'include': 'project,project.customer' diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index 7073a5193..127be35e1 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -14,7 +14,7 @@ def test_task_statistic_list(auth_client, django_assert_num_queries): ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse('task-statistic-list') - with django_assert_num_queries(4): + with django_assert_num_queries(5): result = auth_client.get(url, data={ 'ordering': 'task__name', 'include': 'task,task.project,task.project.customer' diff --git a/timed/reports/views.py b/timed/reports/views.py index eb2d08940..3fe216156 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -79,6 +79,10 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ordering_fields = ('task__project__name', 'duration') ordering = ('task__project__name', ) + prefetch_related_for_field = { + 'task__project': ['reviewers'], + } + def get_queryset(self): queryset = Report.objects.all() @@ -97,6 +101,10 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ordering_fields = ('task__name', 'duration') ordering = ('task__name', ) + prefetch_related_for_field = { + 'task': ['project__reviewers'], + } + def get_queryset(self): queryset = Report.objects.all() From ec07aa677d8711d82d5e57296ba77209f1ee00ad Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 8 Dec 2017 13:29:54 +0100 Subject: [PATCH 380/980] Add count of results to intersection meta --- timed/tracking/serializers.py | 7 +++++++ timed/tracking/tests/test_report.py | 10 ++++++++-- timed/tracking/views.py | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 0f9a2d763..da068bff9 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -287,6 +287,13 @@ def get_verified(self, instance): instance['queryset'] = queryset return self._intersection(instance, 'verified') + def get_root_meta(self, resource, many): + """Add number of results to meta.""" + queryset = self.instance['queryset'] + return { + 'count': queryset.count() + } + included_serializers = { 'customer': 'timed.projects.serializers.CustomerSerializer', 'project': 'timed.projects.serializers.ProjectSerializer', diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 2bd4c22be..96fa66b00 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -89,7 +89,10 @@ def test_report_intersection_full(auth_client): 'type': 'tasks', } } - } + }, + }, + 'meta': { + 'count': 1 } } assert json == expected @@ -118,7 +121,10 @@ def test_report_intersection_partial(auth_client): 'customer': {'data': None}, 'project': {'data': None}, 'task': {'data': None} - } + }, + }, + 'meta': { + 'count': 2 } } assert json == expected diff --git a/timed/tracking/views.py b/timed/tracking/views.py index f149b14d1..ba8f78415 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -166,7 +166,7 @@ def intersection(self, request): del params[param] data = AggregateObject(queryset=queryset, pk=params.urlencode()) - serializer = serializers.ReportIntersectionSerializer(data) + serializer = self.get_serializer(data) return Response(data=serializer.data) @list_route( From 8aa5051499406033c932017195040aaae55b7c3b Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 9 Dec 2017 21:34:04 +0100 Subject: [PATCH 381/980] Update flake8-quotes from 0.12.1 to 0.13.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 492006a91..3a683074d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,7 +6,7 @@ flake8-debugger==3.0.0 flake8-deprecated==1.3 flake8-docstrings==1.1.0 flake8-isort==2.2.2 -flake8-quotes==0.12.1 +flake8-quotes==0.13.0 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 From 8c5e01a351281dac3bc50309b3806652596e8859 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 11 Dec 2017 11:16:04 +0100 Subject: [PATCH 382/980] Update the URL in the reviewer email to match the new frontend --- timed/reports/templates/mail/notify_reviewers_unverified.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/reports/templates/mail/notify_reviewers_unverified.txt b/timed/reports/templates/mail/notify_reviewers_unverified.txt index 2ed1089fa..db92d3971 100644 --- a/timed/reports/templates/mail/notify_reviewers_unverified.txt +++ b/timed/reports/templates/mail/notify_reviewers_unverified.txt @@ -1,3 +1,3 @@ There are unverified reports which need your attention. -Go to <{{protocol}}://{{domain}}/reschedule?from_date={{start}}&to_date={{end}}&reviewer={{reviewer.id}}> +Go to <{{protocol}}://{{domain}}/reschedule?fromDate={{start}}&toDate={{end}}&reviewer={{reviewer.id}}&editable=1> From 37cacf7d4eff79d0e78087b736f8bf9a49c20810 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Mon, 11 Dec 2017 11:25:19 +0100 Subject: [PATCH 383/980] Fix reviewer email tests --- timed/reports/tests/test_notify_reviewers_unverified.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 2294f230e..4cf29b7cf 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -34,7 +34,7 @@ def test_notify_reviewers(db, mailoutbox): mail = mailoutbox[0] assert mail.to == [reviewer_work.email] url = ( - 'http://localhost:4200/reschedule?from_date=2017-07-01&' - 'to_date=2017-07-31&reviewer=%d' + 'http://localhost:4200/reschedule?fromDate=2017-07-01&' + 'toDate=2017-07-31&reviewer=%d&editable=1' ) % reviewer_work.id assert url in mail.body From f8fa9cc72d1d75ad182b251aeab9486e9dea0e31 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 12 Dec 2017 12:34:15 +0100 Subject: [PATCH 384/980] Update django-money from 0.12.1 to 0.12.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b11836396..4e85629e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ pyexcel-xlsx==0.5.4 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 -django-money==0.12.1 +django-money==0.12.2 python-redmine==2.0.2 # TODO: when following PR are released, change back to official release # https://github.com/django-json-api/django-rest-framework-json-api/pull/376 From 8efaa0e3f95b7d601ec9b65dd3c7ef8fac697c4f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 13 Dec 2017 23:49:12 +0100 Subject: [PATCH 385/980] Update django-money from 0.12.2 to 0.12.3 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4e85629e4..b6d8b02c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ pyexcel-xlsx==0.5.4 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 -django-money==0.12.2 +django-money==0.12.3 python-redmine==2.0.2 # TODO: when following PR are released, change back to official release # https://github.com/django-json-api/django-rest-framework-json-api/pull/376 From 3f6d257e1a2574b12856d93a5566b1ad61ae5daa Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 19 Dec 2017 00:04:20 +0100 Subject: [PATCH 386/980] Update pyexcel-xlsx from 0.5.4 to 0.5.5 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b6d8b02c9..c708d20cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyexcel-webio==0.1.4 pyexcel-io==0.5.4 django-excel==0.0.9 pyexcel-ods3==0.5.2 -pyexcel-xlsx==0.5.4 +pyexcel-xlsx==0.5.5 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 From 628194ddb8cece641bee4b751ab0ba2b1ee2ed92 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 20 Dec 2017 09:11:33 +0100 Subject: [PATCH 387/980] Avoid report duplicates by using subqueries instead of joins --- timed/tracking/filters.py | 5 +++-- timed/tracking/tests/test_report.py | 26 ++++++++++++++++++++++---- timed/tracking/views.py | 2 +- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 0fe7ccae5..02f208194 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -117,8 +117,9 @@ def filter_editable(self, queryset, name, value): def get_editable_query(): return ( - Q(user__supervisors=user) | - Q(task__project__reviewers=user) | + # avoid duplicates by using subqueries instead of joins + Q(user__in=user.supervisees.values('id')) | + Q(task__project__in=user.reviews.values('id')) | Q(user=user) ) & Q(verified_by__isnull=True) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 96fa66b00..2d1102d3a 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -220,17 +220,27 @@ def test_report_list_filter_editable_reviewer(auth_client): user = auth_client.user # not editable report ReportFactory.create() + # editable reports + # 1st report of current user ReportFactory.create(user=user) - report = ReportFactory.create() + # 2nd case: report of a project which has several + # reviewers and report is created by current user + report = ReportFactory.create(user=user) + other_user = UserFactory.create() report.task.project.reviewers.add(user) + report.task.project.reviewers.add(other_user) + # 3rd case: report by other user and current user + # is the reviewer + reviewer_report = ReportFactory.create() + reviewer_report.task.project.reviewers.add(user) url = reverse('report-list') response = auth_client.get(url, data={'editable': 1}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 2 + assert len(json['data']) == 3 def test_report_list_filter_editable_superuser(superadmin_client): @@ -260,17 +270,25 @@ def test_report_list_filter_editable_supervisor(auth_client): user = auth_client.user # not editable report ReportFactory.create() + # editable reports + # 1st case: report by current user ReportFactory.create(user=user) - report = ReportFactory.create() + # 2nd case: report by current user with several supervisors + report = ReportFactory.create(user=user) report.user.supervisors.add(user) + other_user = UserFactory.create() + report.user.supervisors.add(other_user) + # 3rd case: report by different user with current user as supervisor + supervisor_report = ReportFactory.create() + supervisor_report.user.supervisors.add(user) url = reverse('report-list') response = auth_client.get(url, data={'editable': 1}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 2 + assert len(json['data']) == 3 def test_report_export_missing_type(auth_client): diff --git a/timed/tracking/views.py b/timed/tracking/views.py index ba8f78415..4bc544602 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -97,7 +97,7 @@ class ReportViewSet(ModelViewSet): # all authenticated users may read all reports C(IsAuthenticated) & C(IsReadOnly) ] - ordering = ('date', ) + ordering = ('date', 'id') ordering_fields = ( 'date', 'duration', From 565f1cb4427fe283d5d2e0ff7f6ccaf099fa2dc3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 21 Dec 2017 11:08:20 +0100 Subject: [PATCH 388/980] Bump to version 0.9.0 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 32a90a3b9..e4e49b3bb 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.8.0' +__version__ = '0.9.0' From b6834c665ade82b4787eafb2d327ecb950713026 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 21 Dec 2017 11:42:12 +0100 Subject: [PATCH 389/980] Adjust bind password of ldap --- timed/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index 642ba0766..d2158dc09 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -202,7 +202,9 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): AUTH_LDAP_SERVER_URI = env.str('DJANGO_AUTH_LDAP_SERVER_URI') AUTH_LDAP_BIND_DN = env.str('DJANGO_AUTH_LDAP_BIND_DN', default='') - AUTH_LDAP_PASSWORD = env.str('DJANGO_AUTH_LDAP_PASSWORD', default='') + AUTH_LDAP_BIND_PASSWORD = env.str( + 'DJANGO_AUTH_LDAP_BIND_PASSWORD', default='' + ) AUTH_LDAP_USER_DN_TEMPLATE = env.str('DJANGO_AUTH_LDAP_USER_DN_TEMPLATE') AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend') From 1a1df9014a5cf6d0634a5b36fc9f864d40a9a0be Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 21 Dec 2017 11:46:41 +0100 Subject: [PATCH 390/980] Bump to version 0.9.1 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index e4e49b3bb..8969d4966 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.9.0' +__version__ = '0.9.1' From e69d67fa899792e29e4bb018e0855bccb0015635 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 22 Dec 2017 15:49:37 +0100 Subject: [PATCH 391/980] Update flake8-isort from 2.2.2 to 2.3 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 3a683074d..628ae644e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -5,7 +5,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.0.0 flake8-deprecated==1.3 flake8-docstrings==1.1.0 -flake8-isort==2.2.2 +flake8-isort==2.3 flake8-quotes==0.13.0 flake8-string-format==0.2.3 ipdb==0.10.3 From ec67eac65428e0d42f4324765433924a9ed3bc0f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 23 Dec 2017 20:34:38 +0100 Subject: [PATCH 392/980] Update pyexcel-io from 0.5.4 to 0.5.5 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c708d20cb..c0bb550f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ djangorestframework-jwt==1.11.0 psycopg2>=2.7,<2.8 pytz==2017.3 pyexcel-webio==0.1.4 -pyexcel-io==0.5.4 +pyexcel-io==0.5.5 django-excel==0.0.9 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.5 From 82b8a5552b16e84583bf8732eb032aa39d82c206 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 3 Jan 2018 11:09:16 +0100 Subject: [PATCH 393/980] Hide absence which are overlapping with public holidays --- timed/tracking/models.py | 14 ++++++++++++++ timed/tracking/tests/test_absence.py | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 4183a9ec1..2ef078167 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -149,6 +149,19 @@ class Meta: indexes = [models.Index(fields=['date'])] +class AbsenceManager(models.Manager): + def get_queryset(self): + from timed.employment.models import PublicHoliday + + queryset = super().get_queryset() + queryset = queryset.exclude(date__in=models.Subquery( + PublicHoliday.objects.filter( + location__employments__user=models.OuterRef('user') + ).values('date') + )) + return queryset + + class Absence(models.Model): """Absence model. @@ -162,6 +175,7 @@ class Absence(models.Model): related_name='absences') user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='absences') + objects = AbsenceManager() def calculate_duration(self, employment): """ diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 4fa89416c..817935284 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -10,6 +10,17 @@ def test_absence_list_authenticated(auth_client): absence = AbsenceFactory.create(user=auth_client.user) + + # overlapping absence with public holidays need to be hidden + overlap_absence = AbsenceFactory.create( + user=auth_client.user, date=datetime.date(2018, 1, 1)) + employment = EmploymentFactory.create( + user=overlap_absence.user, + start_date=datetime.date(2017, 12, 31) + ) + PublicHolidayFactory.create( + date=overlap_absence.date, location=employment.location + ) url = reverse('absence-list') response = auth_client.get(url) From 17130fe39301075991f6fb1d3f0c5fd55041edde Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 4 Jan 2018 03:34:56 +0100 Subject: [PATCH 394/980] Update flake8-docstrings from 1.1.0 to 1.2.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 628ae644e..b2dd0a773 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -4,7 +4,7 @@ flake8==3.5.0 flake8-blind-except==0.1.1 flake8-debugger==3.0.0 flake8-deprecated==1.3 -flake8-docstrings==1.1.0 +flake8-docstrings==1.2.0 flake8-isort==2.3 flake8-quotes==0.13.0 flake8-string-format==0.2.3 From 509c8019aff2f076252ec01300d8ac72b2219251 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 4 Jan 2018 09:32:43 +0100 Subject: [PATCH 395/980] Adjust verified link to new analysis page --- timed/reports/templates/mail/notify_reviewers_unverified.txt | 2 +- timed/reports/tests/test_notify_reviewers_unverified.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/reports/templates/mail/notify_reviewers_unverified.txt b/timed/reports/templates/mail/notify_reviewers_unverified.txt index db92d3971..63f827e2d 100644 --- a/timed/reports/templates/mail/notify_reviewers_unverified.txt +++ b/timed/reports/templates/mail/notify_reviewers_unverified.txt @@ -1,3 +1,3 @@ There are unverified reports which need your attention. -Go to <{{protocol}}://{{domain}}/reschedule?fromDate={{start}}&toDate={{end}}&reviewer={{reviewer.id}}&editable=1> +Go to <{{protocol}}://{{domain}}/analysis?fromDate={{start}}&toDate={{end}}&reviewer={{reviewer.id}}&editable=1> diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 4cf29b7cf..8150c1417 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -34,7 +34,7 @@ def test_notify_reviewers(db, mailoutbox): mail = mailoutbox[0] assert mail.to == [reviewer_work.email] url = ( - 'http://localhost:4200/reschedule?fromDate=2017-07-01&' + 'http://localhost:4200/analysis?fromDate=2017-07-01&' 'toDate=2017-07-31&reviewer=%d&editable=1' ) % reviewer_work.id assert url in mail.body From 4bb3c942feaf731b9f61bef08b2f4ca2571e651c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 4 Jan 2018 10:24:14 +0100 Subject: [PATCH 396/980] Bump version to 0.9.2 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 8969d4966..1f0478037 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.9.1' +__version__ = '0.9.2' From 13eceaa279b2b4a97f4b7b237cd6c8d98d7149f8 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 4 Jan 2018 14:38:11 +0100 Subject: [PATCH 397/980] Update flake8-docstrings from 1.2.0 to 1.3.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index b2dd0a773..ec54d9733 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -4,7 +4,7 @@ flake8==3.5.0 flake8-blind-except==0.1.1 flake8-debugger==3.0.0 flake8-deprecated==1.3 -flake8-docstrings==1.2.0 +flake8-docstrings==1.3.0 flake8-isort==2.3 flake8-quotes==0.13.0 flake8-string-format==0.2.3 From c56d34f76e7c4272b0f4191168d34dcd3dceae62 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 5 Jan 2018 00:21:16 +0100 Subject: [PATCH 398/980] Update pytest from 3.3.1 to 3.3.2 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index ec54d9733..bdc51c9e1 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 mockldap==0.3.0 -pytest==3.3.1 +pytest==3.3.2 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.6.3 From 7cea876c2c79c6f86c7de2c91b07c5021a9b5ea7 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 11 Jan 2018 22:23:23 +0100 Subject: [PATCH 399/980] Update pyexcel-io from 0.5.5 to 0.5.6 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c0bb550f5..3de29d650 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ djangorestframework-jwt==1.11.0 psycopg2>=2.7,<2.8 pytz==2017.3 pyexcel-webio==0.1.4 -pyexcel-io==0.5.5 +pyexcel-io==0.5.6 django-excel==0.0.9 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.5 From 55286887bf5a658545930c6c8c4e2914b4a0adbd Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 13 Jan 2018 00:38:23 +0100 Subject: [PATCH 400/980] Update django-excel from 0.0.9 to 0.0.10 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3de29d650..ee7e1f913 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ psycopg2>=2.7,<2.8 pytz==2017.3 pyexcel-webio==0.1.4 pyexcel-io==0.5.6 -django-excel==0.0.9 +django-excel==0.0.10 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.5 pyexcel-ezodf==0.3.4 From 70f29a4c3bcc5513528f29a85c5974483d054f42 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 15 Jan 2018 10:22:58 +0100 Subject: [PATCH 401/980] Add missing id filter This avoids duplicates during scrolling of reports --- timed/tracking/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 4bc544602..72e128de0 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -99,6 +99,7 @@ class ReportViewSet(ModelViewSet): ] ordering = ('date', 'id') ordering_fields = ( + 'id', 'date', 'duration', 'task__project__customer__name', From 970856205b5bf9c33808e8a024bb7a970c605072 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 23 Jan 2018 08:54:41 +0100 Subject: [PATCH 402/980] Bump version to 0.9.3 --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 1f0478037..ecc0ae4ba 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.9.2' +__version__ = '0.9.3' From 7174657a8006f31ca404f5733ce2e4f0ba56c4d1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 24 Jan 2018 09:24:19 +0100 Subject: [PATCH 403/980] Add frontend link to README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c5a4ba2f2..22c7bddf9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ $ ./manage.py createsuperuser # Create a new Django superuser You can now access the API at http://localhost:8000/api/v1 and the admin interface at http://localhost:8000/admin/ +For end user interface have a look at our [Timed Frontend](https://github.com/adfinis-sygroup/timed-frontend) project. + ## Configuration Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) From da02f94927412bc5a4f3eefd3c589ef4fe788b43 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 25 Jan 2018 10:45:20 +0100 Subject: [PATCH 404/980] Update to newest versions --- requirements.txt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index ee7e1f913..4edc7a4a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ -python-dateutil>=2.6,<2.7 -django>=1.11,<1.12 # pyup: >=1.11,<1.12 +python-dateutil==2.6.1 +django==1.11.9 # pyup: >=1.11,<1.12 django-auth-ldap==1.3.0 django-filter==1.1.0 django-multiselectfield==0.1.8 -djangorestframework==3.6.4 # pyup: >=3.6,<3.7 +djangorestframework==3.7.7 djangorestframework-jwt==1.11.0 -psycopg2>=2.7,<2.8 +djangorestframework-jsonapi==2.4.0 +psycopg2==2.7.3.2 pytz==2017.3 pyexcel-webio==0.1.4 pyexcel-io==0.5.6 @@ -17,7 +18,3 @@ django-environ==0.4.4 rest_condition==1.0.3 django-money==0.12.3 python-redmine==2.0.2 -# TODO: when following PR are released, change back to official release -# https://github.com/django-json-api/django-rest-framework-json-api/pull/376 -# https://github.com/django-json-api/django-rest-framework-json-api/pull/374 -git+https://github.com/adfinis-forks/django-rest-framework-json-api.git@timed_master From 772facdab9c1f025021fc4fb6016dc4344e272a0 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 29 Jan 2018 00:08:48 +0100 Subject: [PATCH 405/980] Update factory-boy from 2.9.2 to 2.10.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index bdc51c9e1..0edc2a65a 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,5 +1,5 @@ coverage==4.4.2 -factory-boy==2.9.2 +factory-boy==2.10.0 flake8==3.5.0 flake8-blind-except==0.1.1 flake8-debugger==3.0.0 From 64ae6e35222486ba587a474ca28ee04af6174951 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 30 Jan 2018 23:36:53 +0100 Subject: [PATCH 406/980] Update pytest from 3.3.2 to 3.4.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 0edc2a65a..5dbdaf896 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.2.15 mockldap==0.3.0 -pytest==3.3.2 +pytest==3.4.0 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.6.3 From c1bb0fa6dcd5be43aac2a04ed8f4752f5bcb63f4 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 1 Feb 2018 07:11:56 +0100 Subject: [PATCH 407/980] Update isort from 4.2.15 to 4.3.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 5dbdaf896..c86c95025 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -9,7 +9,7 @@ flake8-isort==2.3 flake8-quotes==0.13.0 flake8-string-format==0.2.3 ipdb==0.10.3 -isort==4.2.15 +isort==4.3.0 mockldap==0.3.0 pytest==3.4.0 pytest-cov==2.5.1 From 9827b1ea78c5218a71d3dfe839044b597f45881e Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 1 Feb 2018 15:55:56 +0100 Subject: [PATCH 408/980] Update django from 1.11.9 to 1.11.10 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4edc7a4a6..377975fa3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.6.1 -django==1.11.9 # pyup: >=1.11,<1.12 +django==1.11.10 # pyup: >=1.11,<1.12 django-auth-ldap==1.3.0 django-filter==1.1.0 django-multiselectfield==0.1.8 From bd24b2b7e3350ce0241476bbad89f42f24e22e2d Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 2 Feb 2018 20:56:58 +0100 Subject: [PATCH 409/980] Update isort from 4.3.0 to 4.3.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index c86c95025..609a154f6 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -9,7 +9,7 @@ flake8-isort==2.3 flake8-quotes==0.13.0 flake8-string-format==0.2.3 ipdb==0.10.3 -isort==4.3.0 +isort==4.3.1 mockldap==0.3.0 pytest==3.4.0 pytest-cov==2.5.1 From 5a453c1daa7bd24f6be1c19711ca23b0d4830ae1 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 9 Feb 2018 09:12:09 +0100 Subject: [PATCH 410/980] Update pytz from 2017.3 to 2018.3 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 377975fa3..37dfa3992 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.7.7 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.4.0 psycopg2==2.7.3.2 -pytz==2017.3 +pytz==2018.3 pyexcel-webio==0.1.4 pyexcel-io==0.5.6 django-excel==0.0.10 From 12a24dbc7c924944462d823ac99a6358fed70b0e Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 10 Feb 2018 22:10:14 +0100 Subject: [PATCH 411/980] Update coverage from 4.4.2 to 4.5.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 609a154f6..0292d57e5 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,4 +1,4 @@ -coverage==4.4.2 +coverage==4.5.1 factory-boy==2.10.0 flake8==3.5.0 flake8-blind-except==0.1.1 From 73ff2199d692ba2d3ac92c3e36467e7753489e87 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sun, 11 Feb 2018 18:39:16 +0100 Subject: [PATCH 412/980] Update flake8-debugger from 3.0.0 to 3.1.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 609a154f6..5b8b51149 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -2,7 +2,7 @@ coverage==4.4.2 factory-boy==2.10.0 flake8==3.5.0 flake8-blind-except==0.1.1 -flake8-debugger==3.0.0 +flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-isort==2.3 From 8c2bc3e5b61d4a57f6702de3c6ef946173b318d7 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 15 Feb 2018 12:54:22 +0100 Subject: [PATCH 413/980] Update ipdb from 0.10.3 to 0.11 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 609a154f6..6f23bd9b0 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -8,7 +8,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.3 flake8-quotes==0.13.0 flake8-string-format==0.2.3 -ipdb==0.10.3 +ipdb==0.11 isort==4.3.1 mockldap==0.3.0 pytest==3.4.0 From 69f37f021cc8726bb026792a77bcf74186ba6ff1 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 16 Feb 2018 22:11:24 +0100 Subject: [PATCH 414/980] Update pytest-mock from 1.6.3 to 1.7.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 609a154f6..1a7b7f21d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -14,7 +14,7 @@ mockldap==0.3.0 pytest==3.4.0 pytest-cov==2.5.1 pytest-env==0.6.2 -pytest-mock==1.6.3 +pytest-mock==1.7.0 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af From 43b5c1f51dcbe3bc09cb097993175ab48ab6a256 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 20 Feb 2018 05:10:23 +0100 Subject: [PATCH 415/980] Update flake8-quotes from 0.13.0 to 0.13.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 609a154f6..46d1007a0 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,7 +6,7 @@ flake8-debugger==3.0.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-isort==2.3 -flake8-quotes==0.13.0 +flake8-quotes==0.13.1 flake8-string-format==0.2.3 ipdb==0.10.3 isort==4.3.1 From aca7b9b067cd81a32581518c35ee05000840d1fe Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 20 Feb 2018 14:41:10 +0100 Subject: [PATCH 416/980] Update isort from 4.3.1 to 4.3.4 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 71c045fb3..eb4044513 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -9,7 +9,7 @@ flake8-isort==2.3 flake8-quotes==0.13.1 flake8-string-format==0.2.3 ipdb==0.11 -isort==4.3.1 +isort==4.3.4 mockldap==0.3.0 pytest==3.4.0 pytest-cov==2.5.1 From c921bb5b91e32a84c28e726f1613990eda20ac91 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 20 Feb 2018 15:10:50 +0100 Subject: [PATCH 417/980] Update psycopg2 from 2.7.3.2 to 2.7.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 37dfa3992..3b68c1b73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.7.7 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.4.0 -psycopg2==2.7.3.2 +psycopg2==2.7.4 pytz==2018.3 pyexcel-webio==0.1.4 pyexcel-io==0.5.6 From a7ddb1da09a9181bde3f222709ee172ea29e3e2b Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 20 Feb 2018 22:37:31 +0100 Subject: [PATCH 418/980] Update pytest from 3.4.0 to 3.4.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 5093705e1..efe489be9 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.4.0 +pytest==3.4.1 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.7.0 From f4729fdba16108c99c3b7a31cd71250d499ae1bb Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sun, 25 Feb 2018 00:54:08 +0100 Subject: [PATCH 419/980] Update flake8-isort from 2.3 to 2.4 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index efe489be9..b5b953a93 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -5,7 +5,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 -flake8-isort==2.3 +flake8-isort==2.4 flake8-quotes==0.13.1 flake8-string-format==0.2.3 ipdb==0.11 From 377a7581f1a92153c8a18211fa3626679cb7e02a Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 26 Feb 2018 09:43:51 +0100 Subject: [PATCH 420/980] Update flake8-quotes from 0.13.1 to 0.14.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index b5b953a93..021cca85e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,7 +6,7 @@ flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-isort==2.4 -flake8-quotes==0.13.1 +flake8-quotes==0.14.0 flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 From 8ce73289a79c90a455bb179e204d160db43b3b71 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 1 Mar 2018 12:11:17 +0100 Subject: [PATCH 421/980] Update pytest-mock from 1.7.0 to 1.7.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 021cca85e..c030ebff4 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -14,7 +14,7 @@ mockldap==0.3.0 pytest==3.4.1 pytest-cov==2.5.1 pytest-env==0.6.2 -pytest-mock==1.7.0 +pytest-mock==1.7.1 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af From cd9b221b5f61d5cdcdb8cb3c1cbd06073c26a8c6 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 6 Mar 2018 00:22:31 +0100 Subject: [PATCH 422/980] Update pytest from 3.4.1 to 3.4.2 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index c030ebff4..47100a4eb 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.4.1 +pytest==3.4.2 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.7.1 From d16e8ce365783a2fdf613295ce2e95c4d10f8f9b Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 6 Mar 2018 15:26:31 +0100 Subject: [PATCH 423/980] Update django from 1.11.10 to 1.11.11 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3b68c1b73..92123d7da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.6.1 -django==1.11.10 # pyup: >=1.11,<1.12 +django==1.11.11 # pyup: >=1.11,<1.12 django-auth-ldap==1.3.0 django-filter==1.1.0 django-multiselectfield==0.1.8 From 00fcbb689b80b57f2d0918d187012fac078f4854 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Mar 2018 00:57:36 +0100 Subject: [PATCH 424/980] Update python-dateutil from 2.6.1 to 2.7.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92123d7da..7f935180b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.6.1 +python-dateutil==2.7.0 django==1.11.11 # pyup: >=1.11,<1.12 django-auth-ldap==1.3.0 django-filter==1.1.0 From 6d8fa615a33292b649ff77a3a5c750479b5b0f87 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 15 Mar 2018 09:54:39 +0100 Subject: [PATCH 425/980] Update flake8-isort from 2.4 to 2.5 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 47100a4eb..281ac12f9 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -5,7 +5,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 -flake8-isort==2.4 +flake8-isort==2.5 flake8-quotes==0.14.0 flake8-string-format==0.2.3 ipdb==0.11 From 1d99dd2a297969acd2a097aa6b0368262e5d07d9 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 23 Mar 2018 00:54:52 +0100 Subject: [PATCH 426/980] Update pytest from 3.4.2 to 3.5.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 281ac12f9..35fdd2377 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.4.2 +pytest==3.5.0 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.7.1 From 15bb262be1b94425298e4978f797eada79910d53 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 23 Mar 2018 00:54:56 +0100 Subject: [PATCH 427/980] Update django-auth-ldap from 1.3.0 to 1.4.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7f935180b..bc85bddf0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ python-dateutil==2.7.0 django==1.11.11 # pyup: >=1.11,<1.12 -django-auth-ldap==1.3.0 +django-auth-ldap==1.4.0 django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework==3.7.7 From 6bb2b7f0fbb050b314dc13fe21e88356d4a976bd Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 24 Mar 2018 20:28:58 +0100 Subject: [PATCH 428/980] Update python-dateutil from 2.7.0 to 2.7.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bc85bddf0..d054aed7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.7.0 +python-dateutil==2.7.1 django==1.11.11 # pyup: >=1.11,<1.12 django-auth-ldap==1.4.0 django-filter==1.1.0 From 12d0b273c65f857db371395459f300ccc4206d28 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 26 Mar 2018 17:14:21 +0200 Subject: [PATCH 429/980] Update python-dateutil from 2.7.1 to 2.7.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d054aed7f..7bd2fb2ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.7.1 +python-dateutil==2.7.2 django==1.11.11 # pyup: >=1.11,<1.12 django-auth-ldap==1.4.0 django-filter==1.1.0 From 4bd008cf462c1435b7f0ee1a220f6d5eff7d194f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 27 Mar 2018 00:40:05 +0200 Subject: [PATCH 430/980] Update pyexcel-xlsx from 0.5.5 to 0.5.6 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d054aed7f..c60d18f05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyexcel-webio==0.1.4 pyexcel-io==0.5.6 django-excel==0.0.10 pyexcel-ods3==0.5.2 -pyexcel-xlsx==0.5.5 +pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 From 78f3794da918a71403e9a94ed457872b5c43c094 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 30 Mar 2018 06:56:07 +0200 Subject: [PATCH 431/980] Update flake8-quotes from 0.14.0 to 0.14.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 35fdd2377..f98bd044e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,7 +6,7 @@ flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-isort==2.5 -flake8-quotes==0.14.0 +flake8-quotes==0.14.1 flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 From ca9e11f747e692e2ba834a1d028b3b45235eda90 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 3 Apr 2018 05:12:17 +0200 Subject: [PATCH 432/980] Update django from 1.11.11 to 1.11.12 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b602d8aaf..c69c6a50f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.7.2 -django==1.11.11 # pyup: >=1.11,<1.12 +django==1.11.12 # pyup: >=1.11,<1.12 django-auth-ldap==1.4.0 django-filter==1.1.0 django-multiselectfield==0.1.8 From 27473578ca50ace30687107d5159d5da20c6b0b1 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 7 Apr 2018 05:58:09 +0200 Subject: [PATCH 433/980] Update pytest-mock from 1.7.1 to 1.8.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index f98bd044e..a80d7684a 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -14,7 +14,7 @@ mockldap==0.3.0 pytest==3.5.0 pytest-cov==2.5.1 pytest-env==0.6.2 -pytest-mock==1.7.1 +pytest-mock==1.8.0 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af From 31600b25060e9eb69f83b6e94053765462d7b4ab Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 7 Apr 2018 19:25:09 +0200 Subject: [PATCH 434/980] Update django-money from 0.12.3 to 0.13.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c69c6a50f..9b8b62816 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 -django-money==0.12.3 +django-money==0.13.1 python-redmine==2.0.2 From 297ccb4ab652d50e6e3776b38f49b51b4ec2a192 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sun, 8 Apr 2018 22:41:12 +0200 Subject: [PATCH 435/980] Update flake8-quotes from 0.14.1 to 1.0.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index f98bd044e..26abe56ad 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,7 +6,7 @@ flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-isort==2.5 -flake8-quotes==0.14.1 +flake8-quotes==1.0.0 flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 From 463ebe963d4e80bb94d78b5d3dfbd53f9b8bfb87 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 10 Apr 2018 05:13:17 +0200 Subject: [PATCH 436/980] Update pytest-mock from 1.8.0 to 1.9.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index ee8dac224..ea96be8d0 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -14,7 +14,7 @@ mockldap==0.3.0 pytest==3.5.0 pytest-cov==2.5.1 pytest-env==0.6.2 -pytest-mock==1.8.0 +pytest-mock==1.9.0 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af From 41fefbcb20357f3c33b8a24add728f5fb23e7b9f Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 10 Apr 2018 12:59:19 +0200 Subject: [PATCH 437/980] Update pytz from 2018.3 to 2018.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9b8b62816..6f12c93be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.7.7 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.4.0 psycopg2==2.7.4 -pytz==2018.3 +pytz==2018.4 pyexcel-webio==0.1.4 pyexcel-io==0.5.6 django-excel==0.0.10 From 49b29e9296b92fe4bc7aa194abc314f6f5269931 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 16 Apr 2018 21:40:30 +0200 Subject: [PATCH 438/980] Update django-money from 0.13.1 to 0.13.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6f12c93be..3b4cd6354 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 -django-money==0.13.1 +django-money==0.13.2 python-redmine==2.0.2 From 45865e8b8b3831100cce5a1548d7e66a2d64ae00 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 19 Apr 2018 04:10:31 +0200 Subject: [PATCH 439/980] Update django-auth-ldap from 1.4.0 to 1.5.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3b4cd6354..782db2b06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ python-dateutil==2.7.2 django==1.11.12 # pyup: >=1.11,<1.12 -django-auth-ldap==1.4.0 +django-auth-ldap==1.5.0 django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework==3.7.7 From e6d185c4334f588b83a78dc5e0d6702ec5d02180 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 25 Apr 2018 00:08:47 +0200 Subject: [PATCH 440/980] Update pytest from 3.5.0 to 3.5.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index ea96be8d0..4911dc26b 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.5.0 +pytest==3.5.1 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.9.0 From fdfbc758046f3245f7d68b3328f1c6a223470600 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 1 May 2018 19:28:57 +0200 Subject: [PATCH 441/980] Update pytest-mock from 1.9.0 to 1.10.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 4911dc26b..00f4d5299 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -14,7 +14,7 @@ mockldap==0.3.0 pytest==3.5.1 pytest-cov==2.5.1 pytest-env==0.6.2 -pytest-mock==1.9.0 +pytest-mock==1.10.0 # once newer version than 3.1.2 has been released # pytest-django can be imported through pypi again git+https://github.com/pytest-dev/pytest-django@21492af From f8f4bf250bdd4999ea019b39cd4a9c5d251d78e6 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 2 May 2018 04:28:01 +0200 Subject: [PATCH 442/980] Update django from 1.11.12 to 1.11.13 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 782db2b06..41fe87117 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.7.2 -django==1.11.12 # pyup: >=1.11,<1.12 +django==1.11.13 # pyup: >=1.11,<1.12 django-auth-ldap==1.5.0 django-filter==1.1.0 django-multiselectfield==0.1.8 From 801c8a691417d8f2aa8a723632bfc9c006505404 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 2 May 2018 15:55:58 +0200 Subject: [PATCH 443/980] Update python-redmine from 2.0.2 to 2.1.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 41fe87117..68c87452a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,4 @@ pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 django-money==0.13.2 -python-redmine==2.0.2 +python-redmine==2.1.0 From 4e83c6ba46b5cc2fa6c74cc8103109c796ab6b73 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 2 May 2018 19:10:58 +0200 Subject: [PATCH 444/980] Update python-redmine from 2.1.0 to 2.1.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 68c87452a..0ea66e2ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,4 @@ pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 django-money==0.13.2 -python-redmine==2.1.0 +python-redmine==2.1.1 From 6bea9538f767e428cff5d58e1a54d744c6865da9 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 3 May 2018 00:26:00 +0200 Subject: [PATCH 445/980] Update pyexcel-io from 0.5.6 to 0.5.7 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 68c87452a..e01a1c1b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.4.0 psycopg2==2.7.4 pytz==2018.4 pyexcel-webio==0.1.4 -pyexcel-io==0.5.6 +pyexcel-io==0.5.7 django-excel==0.0.10 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.6 From e0b765bd36c15d5b576d329a8cbf99b06f69d3b3 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 5 May 2018 17:11:03 +0200 Subject: [PATCH 446/980] Update factory-boy from 2.10.0 to 2.11.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 00f4d5299..7244c6b71 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,5 +1,5 @@ coverage==4.5.1 -factory-boy==2.10.0 +factory-boy==2.11.1 flake8==3.5.0 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 From 34903f2ad942f1546251dffe00537ef32d98b262 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 10 May 2018 14:15:13 +0200 Subject: [PATCH 447/980] Update python-dateutil from 2.7.2 to 2.7.3 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91e0d5d02..7ec2e7479 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.7.2 +python-dateutil==2.7.3 django==1.11.13 # pyup: >=1.11,<1.12 django-auth-ldap==1.5.0 django-filter==1.1.0 From 9bd695ec0e786fe8a3d6e2ab7e99c59edbb2dade Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 19 May 2018 19:11:31 +0200 Subject: [PATCH 448/980] Update django-money from 0.13.2 to 0.13.5 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7ec2e7479..ce6250bf6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 -django-money==0.13.2 +django-money==0.13.5 python-redmine==2.1.1 From 43754949735652a6e81b114d48742e91be24ac14 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 23 May 2018 13:11:37 +0200 Subject: [PATCH 449/980] Update pytest from 3.5.1 to 3.6.0 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 7244c6b71..d4cd4e185 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.5.1 +pytest==3.6.0 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From 82011bcd4209666df97aefe5874a4fcfc07b45e3 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 2 Jun 2018 20:42:03 +0200 Subject: [PATCH 450/980] Update django-auth-ldap from 1.5.0 to 1.6.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ce6250bf6..0569f3044 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ python-dateutil==2.7.3 django==1.11.13 # pyup: >=1.11,<1.12 -django-auth-ldap==1.5.0 +django-auth-ldap==1.6.1 django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework==3.7.7 From 89afb11818c0f1a90c29deeb2ead217435ca10fd Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 5 Jun 2018 14:28:05 +0200 Subject: [PATCH 451/980] Update pytest from 3.6.0 to 3.6.1 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index d4cd4e185..edd0d4734 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.6.0 +pytest==3.6.1 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From b048e655408afec4604a0e5afd7e99fe3c15334a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 8 Jun 2018 16:49:38 +0200 Subject: [PATCH 452/980] Clarify how to configure timed backend --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 22c7bddf9..19f31cdb0 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,20 @@ Timed timetracking software REST API built with Django ## Installation + **Requirements** -* python 3.5.2 +* Min. python 3.5 * docker * docker-compose After installing and configuring those requirements, you should be able to run the following commands to complete the installation: ```bash -$ echo "ENV=dev" >> .env # Django settings will be configured for development -$ make install # Install Python requirements -$ docker-compose up -d db # Start the containers -$ ./manage.py migrate # Run Django migrations -$ ./manage.py createsuperuser # Create a new Django superuser +# configure environment variables as listed in configuration chapter below +make install +docker-compose up -d db +./manage.py migrate +./manage.py createsuperuser ``` You can now access the API at http://localhost:8000/api/v1 and the admin interface at http://localhost:8000/admin/ @@ -34,6 +35,7 @@ according to type. | Parameter | Description | Default | | ----------------------------------- | ---------------------------------------------------------- | -------------------------------- | +| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | | `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | | `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | | `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | @@ -55,6 +57,10 @@ according to type. | `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | +## Development + +For development setup you can set environment variables `ENV=dev`. This way default values will be used. NOT TO BE USED IN PRODUCTION! + ## Testing Run tests by executing `make test` From c0a53a66bb60a32bfc828fa9a278310328da6de2 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 8 Jun 2018 16:30:41 +0200 Subject: [PATCH 453/980] Update to rest framework 3.8.2 Beside some deprecation warnings main issue to fix was that read_only and default may not be used together anymore. Replaced with CurrentUserResourceRelatedField. --- .gitignore | 3 +++ requirements.txt | 2 +- timed/employment/relations.py | 15 +++++++++++++++ timed/employment/views.py | 4 ++-- timed/tracking/serializers.py | 19 +++++++++---------- timed/tracking/views.py | 10 ++++++---- 6 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 timed/employment/relations.py diff --git a/.gitignore b/.gitignore index f43a7dba7..cba1df755 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ target/ # local .env file .env + +# pytest +.pytest_cache diff --git a/requirements.txt b/requirements.txt index 0569f3044..a9df1c958 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==1.11.13 # pyup: >=1.11,<1.12 django-auth-ldap==1.6.1 django-filter==1.1.0 django-multiselectfield==0.1.8 -djangorestframework==3.7.7 +djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.4.0 psycopg2==2.7.4 diff --git a/timed/employment/relations.py b/timed/employment/relations.py new file mode 100644 index 000000000..cdc20befd --- /dev/null +++ b/timed/employment/relations.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import CurrentUserDefault + + +class CurrentUserResourceRelatedField(ResourceRelatedField): + """User resource related field restricting user to current user.""" + + def __init__(self, *args, **kwargs): + kwargs['default'] = CurrentUserDefault() + super().__init__(*args, **kwargs) + + def get_queryset(self): + request = self.context['request'] + return get_user_model().objects.filter(pk=request.user.pk) diff --git a/timed/employment/views.py b/timed/employment/views.py index 315b0b1f3..0bee4edf2 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_condition import C from rest_framework import exceptions, status -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet @@ -48,7 +48,7 @@ def get_queryset(self): 'employments', 'supervisees', 'supervisors' ) - @detail_route(methods=['post']) + @action(methods=['post'], detail=True) def transfer(self, request, pk=None): """ Transfer worktime and absence balance to new year. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index da068bff9..5d6d5a437 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -7,13 +7,13 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api import relations, serializers from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import (CurrentUserDefault, - DurationField, +from rest_framework_json_api.serializers import (DurationField, ModelSerializer, Serializer, SerializerMethodField, ValidationError) from timed.employment.models import AbsenceType, Employment, PublicHoliday +from timed.employment.relations import CurrentUserResourceRelatedField from timed.projects.models import Customer, Project, Task from timed.serializers import TotalTimeRootMetaMixin from timed.tracking import models @@ -23,8 +23,7 @@ class ActivitySerializer(ModelSerializer): """Activity serializer.""" duration = DurationField(read_only=True) - user = ResourceRelatedField(read_only=True, - default=CurrentUserDefault()) + user = CurrentUserResourceRelatedField() task = ResourceRelatedField(queryset=Task.objects.all(), allow_null=True, required=False) @@ -99,7 +98,7 @@ class Meta: class AttendanceSerializer(ModelSerializer): """Attendance serializer.""" - user = ResourceRelatedField(read_only=True, default=CurrentUserDefault()) + user = CurrentUserResourceRelatedField() included_serializers = { 'user': 'timed.employment.serializers.UserSerializer', @@ -124,8 +123,7 @@ class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): activity = ResourceRelatedField(queryset=models.Activity.objects.all(), allow_null=True, required=False) - user = ResourceRelatedField(read_only=True, - default=CurrentUserDefault()) + user = CurrentUserResourceRelatedField() verified_by = ResourceRelatedField(queryset=get_user_model().objects, required=False, allow_null=True) @@ -309,8 +307,7 @@ class AbsenceSerializer(ModelSerializer): duration = SerializerMethodField(source='get_duration') type = ResourceRelatedField(queryset=AbsenceType.objects.all()) - user = ResourceRelatedField(read_only=True, - default=CurrentUserDefault()) + user = CurrentUserResourceRelatedField() included_serializers = { 'user': 'timed.employment.serializers.UserSerializer', @@ -356,9 +353,11 @@ def validate(self, data): :returns: The validated data :rtype: dict """ + instance = self.instance + user = data.get('user', instance and instance.user) try: location = Employment.objects.get_at( - data.get('user'), + user, data.get('date') ).location except Employment.DoesNotExist: diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 72e128de0..e82a2f6a7 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_condition import C from rest_framework import exceptions, status -from rest_framework.decorators import list_route +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -138,7 +138,8 @@ def _extract_billing_type(self, report): return name - @list_route( + @action( + detail=False, methods=['get'], serializer_class=serializers.ReportIntersectionSerializer, ) @@ -170,7 +171,8 @@ def intersection(self, request): serializer = self.get_serializer(data) return Response(data=serializer.data) - @list_route( + @action( + detail=False, methods=['post'], # all users are allowed to bulk update but only on filtered result permission_classes=[IsAuthenticated], @@ -219,7 +221,7 @@ def bulk(self, request): return Response(status=status.HTTP_204_NO_CONTENT) - @list_route(methods=['get']) + @action(methods=['get'], detail=False) def export(self, request): """Export filtered reports to given file format.""" queryset = self.get_queryset().select_related( From f9b5da53334b8b9a91c4382be82e3cbcd585ec95 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 9 Jun 2018 20:27:12 +0200 Subject: [PATCH 454/980] Update django-money from 0.13.5 to 0.14 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0569f3044..188467827 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.4 rest_condition==1.0.3 -django-money==0.13.5 +django-money==0.14 python-redmine==2.1.1 From da5c25243082a69acbf8923ae5b298f63005edc4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Oct 2017 16:19:18 +0200 Subject: [PATCH 455/980] Add subscription api end points In this step merging SubscriptionProject and BillingType --- timed/projects/admin.py | 8 +- timed/subscription/admin.py | 32 +------ timed/subscription/factories.py | 21 +++++ timed/subscription/filters.py | 33 +++++++ .../migrations/0003_auto_20170907_1151.py | 73 +++++++++++++++ timed/subscription/models.py | 49 ++-------- timed/subscription/serializers.py | 90 +++++++++++++++++++ timed/subscription/tests/test_order.py | 59 ++++++++++++ timed/subscription/tests/test_package.py | 16 ++++ .../tests/test_subscription_project.py | 67 ++++++++++++++ timed/subscription/urls.py | 14 +++ timed/subscription/views.py | 77 ++++++++++++++++ timed/urls.py | 3 +- 13 files changed, 462 insertions(+), 80 deletions(-) create mode 100644 timed/subscription/factories.py create mode 100644 timed/subscription/filters.py create mode 100644 timed/subscription/migrations/0003_auto_20170907_1151.py create mode 100644 timed/subscription/serializers.py create mode 100644 timed/subscription/tests/test_order.py create mode 100644 timed/subscription/tests/test_package.py create mode 100644 timed/subscription/tests/test_subscription_project.py create mode 100644 timed/subscription/urls.py create mode 100644 timed/subscription/views.py diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 263772276..19b73c2ef 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -8,8 +8,6 @@ from timed.forms import DurationInHoursField from timed.projects import models from timed.redmine.admin import RedmineProjectInline -from timed.subscription.admin import (CustomerPasswordInline, - SubscriptionProjectInline) @admin.register(models.Customer) @@ -18,9 +16,6 @@ class CustomerAdmin(admin.ModelAdmin): list_display = ['name'] search_fields = ['name'] - inlines = [ - CustomerPasswordInline - ] def has_delete_permission(self, request, obj=None): return obj and not obj.projects.exists() @@ -112,8 +107,7 @@ class ProjectAdmin(admin.ModelAdmin): inlines = [ TaskInline, ReviewerInline, - RedmineProjectInline, - SubscriptionProjectInline + RedmineProjectInline ] exclude = ('reviewers', ) diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py index c2db01497..69ca28cd9 100644 --- a/timed/subscription/admin.py +++ b/timed/subscription/admin.py @@ -1,38 +1,8 @@ -import hashlib - -from django import forms from django.contrib import admin from . import models -@admin.register(models.Subscription) -class SubscriptionAdmin(admin.ModelAdmin): - """Subscription admin view.""" - - list_display = ['name', 'archived'] - - @admin.register(models.Package) class PackageAdmin(admin.ModelAdmin): - """Attendance admin view.""" - - list_display = ['subscription', 'duration', 'price'] - - -class SubscriptionProjectInline(admin.StackedInline): - model = models.SubscriptionProject - - -class CustomerPasswordForm(forms.ModelForm): - def save(self, commit=True): - password = self.cleaned_data.get('password') - if password is not None: - self.instance.password = hashlib.md5( - password.encode()).hexdigest() - return super().save(commit=commit) - - -class CustomerPasswordInline(admin.StackedInline): - form = CustomerPasswordForm - model = models.CustomerPassword + list_display = ['billing_type', 'duration', 'price'] diff --git a/timed/subscription/factories.py b/timed/subscription/factories.py new file mode 100644 index 000000000..200ab3e27 --- /dev/null +++ b/timed/subscription/factories.py @@ -0,0 +1,21 @@ +from factory import Faker, SubFactory +from factory.django import DjangoModelFactory + +from . import models + + +class OrderFactory(DjangoModelFactory): + project = SubFactory('timed.projects.factories.ProjectFactory') + duration = Faker('time_delta') + + class Meta: + model = models.Order + + +class PackageFactory(DjangoModelFactory): + billing_type = SubFactory('timed.projects.factories.BillingTypeFactory') + duration = Faker('time_delta') + price = Faker('pydecimal', positive=True, left_digits=4, right_digits=2) + + class Meta: + model = models.Package diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py new file mode 100644 index 000000000..33bcdefba --- /dev/null +++ b/timed/subscription/filters.py @@ -0,0 +1,33 @@ +from django_filters import FilterSet, NumberFilter + +from timed.projects.models import Project + +from . import models + + +class SubscriptionProjectFilter(FilterSet): + class Meta: + model = Project + fields = ( + 'billing_type', + ) + + +class PackageFilter(FilterSet): + class Meta: + model = models.Package + fields = ( + 'billing_type', + ) + + +class OrderFilter(FilterSet): + customer = NumberFilter(name='project__customer') + + class Meta: + model = models.Order + fields = ( + 'customer', + 'project', + 'acknowledged' + ) diff --git a/timed/subscription/migrations/0003_auto_20170907_1151.py b/timed/subscription/migrations/0003_auto_20170907_1151.py new file mode 100644 index 000000000..713600c3f --- /dev/null +++ b/timed/subscription/migrations/0003_auto_20170907_1151.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-07 09:51 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +SUBSCRIPTION_TO_BILLINGTYPE = { + 'DL-Budget': 'Engineering Budget', + 'SLA Störungsbehebung': 'SLA Incident Management', + 'Software Maintenance Abonnement': 'Software Maintenance', + 'SySupport-Premium': 'SSA Premium', + 'SySupport-Standard': 'SSA Standard' +} + + +def migrate_packages(apps, schema_editor): + """Map package subscription to billing type.""" + Package = apps.get_model('subscription', 'Package') + BillingType = apps.get_model('projects', 'BillingType') + + for subscription, billing_type in SUBSCRIPTION_TO_BILLINGTYPE.items(): + pkgs = Package.objects.filter(subscription__name=subscription) + if pkgs.exists(): + billing_type, _ = BillingType.objects.get_or_create(name=billing_type) + pkgs.update(billing_type=billing_type) + + # delete all obsolete packages + Package.objects.filter(billing_type__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0005_auto_20170907_0938'), + ('subscription', '0002_auto_20170808_1729'), + ] + + operations = [ + migrations.RemoveField( + model_name='customerpassword', + name='customer', + ), + migrations.RemoveField( + model_name='subscriptionproject', + name='project', + ), + migrations.RemoveField( + model_name='subscriptionproject', + name='subscription', + ), + migrations.AddField( + model_name='package', + name='billing_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='projects.BillingType', related_name='packages'), + preserve_default=False, + ), + migrations.RunPython(migrate_packages), + migrations.RemoveField( + model_name='package', + name='subscription', + ), + migrations.DeleteModel( + name='CustomerPassword', + ), + migrations.DeleteModel( + name='Subscription', + ), + migrations.DeleteModel( + name='SubscriptionProject', + ), + ] diff --git a/timed/subscription/models.py b/timed/subscription/models.py index e013e629c..5f3925f3a 100644 --- a/timed/subscription/models.py +++ b/timed/subscription/models.py @@ -1,43 +1,23 @@ from django.conf import settings from django.db import models from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ from djmoney.models.fields import MoneyField -class Subscription(models.Model): - """Representation of a support subscription.""" - - name = models.CharField(max_length=255) - archived = models.BooleanField(default=False) - - def __str__(self): - """Represent the model as a string. - - :return: The string representation - :rtype: str - """ - return self.name - - class Package(models.Model): """Representing a subscription package.""" - subscription = models.ForeignKey(Subscription) - duration = models.DurationField() - price = MoneyField(max_digits=7, decimal_places=2, - default_currency='CHF') - - -class SubscriptionProject(models.Model): + billing_type = models.ForeignKey('projects.BillingType', null=True, + related_name='packages') """ - Assign subscription to project. - - A project can only be assigned to one subscription. + This field has been added later so there might be old entries with null + hence null=True. However blank=True is not set as it is required to set + for new packages. """ - project = models.OneToOneField('projects.Project') - subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE) + duration = models.DurationField() + price = MoneyField(max_digits=7, decimal_places=2, + default_currency='CHF') class Order(models.Model): @@ -53,16 +33,3 @@ class Order(models.Model): on_delete=models.SET_NULL, null=True, blank=True, related_name='orders_confirmed') - - -class CustomerPassword(models.Model): - """ - Password per customer used for login into SySupport portal. - - Password are only hashed with md5. This model will be obsolete - once customer center will go live. - """ - - customer = models.OneToOneField('projects.Customer') - password = models.CharField(_('password'), max_length=128, - null=True, blank=True) diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py new file mode 100644 index 000000000..eba821a5d --- /dev/null +++ b/timed/subscription/serializers.py @@ -0,0 +1,90 @@ +from datetime import timedelta + +from django.db.models import Sum +from django.utils.duration import duration_string +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import (CharField, ModelSerializer, + SerializerMethodField) + +from timed.projects.models import BillingType, Project +from timed.tracking.models import Report + +from .models import Order, Package + + +class SubscriptionProjectSerializer(ModelSerializer): + billing_type = ResourceRelatedField(queryset=BillingType.objects.all()) + purchased_time = SerializerMethodField(source='get_purchased_time') + spent_time = SerializerMethodField(source='get_spent_time') + + def get_purchased_time(self, obj): + """ + Calculate purchased time for given project. + + Only acknowledged hours are included. + """ + orders = Order.objects.filter(project=obj, acknowledged=True) + data = orders.aggregate(purchased_time=Sum('duration')) + return duration_string(data['purchased_time'] or timedelta(0)) + + def get_spent_time(self, obj): + """ + Calculate spent time for given project. + + Reports which are not billable or are in review are excluded. + """ + reports = Report.objects.filter(task__project=obj, not_billable=False, + review=False) + data = reports.aggregate(spent_time=Sum('duration')) + return duration_string(data['spent_time'] or timedelta()) + + included_serializers = { + 'billing_type': 'timed.projects.serializers.BillingTypeSerializer' + } + + class Meta: + model = Project + resource_name = 'subscription-project' + fields = ( + 'name', + 'billing_type', + 'purchased_time', + 'spent_time' + ) + + +class PackageSerializer(ModelSerializer): + billing_type = ResourceRelatedField(queryset=BillingType.objects.all()) + price = CharField() + """CharField needed as it includes currency.""" + + included_serializers = { + 'billing_type': 'timed.projects.serializers.BillingTypeSerializer' + } + + class Meta: + model = Package + resource_name = 'subscription-package' + fields = ( + 'duration', + 'price', + 'billing_type' + ) + + +class OrderSerializer(ModelSerializer): + project = ResourceRelatedField(queryset=Project.objects.all()) + + included_serializers = { + 'project': 'timed.projects.serializers.ProjectSerializer', + } + + class Meta: + model = Order + resource_name = 'subscription-order' + fields = ( + 'duration', + 'acknowledged', + 'ordered', + 'project' + ) diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py new file mode 100644 index 000000000..00fb01ebb --- /dev/null +++ b/timed/subscription/tests/test_order.py @@ -0,0 +1,59 @@ +from django.core.urlresolvers import reverse +from rest_framework import status + +from timed.subscription import factories + + +def test_order_list(auth_client): + factories.OrderFactory.create() + + url = reverse('subscription-order-list') + + res = auth_client.get(url) + assert res.status_code == status.HTTP_200_OK + + json = res.json() + assert len(json['data']) == 1 + + +def test_order_delete(auth_client): + order = factories.OrderFactory.create() + + url = reverse('subscription-order-detail', args=[order.id]) + + res = auth_client.delete(url) + assert res.status_code == status.HTTP_204_NO_CONTENT + + +def test_order_delete_confirmed(auth_client): + """Deleting of confirmed order should not be possible.""" + order = factories.OrderFactory(acknowledged=True) + + url = reverse('subscription-order-detail', args=[order.id]) + + res = auth_client.delete(url) + assert res.status_code == status.HTTP_403_FORBIDDEN + + +def test_order_confirm_admin(admin_client): + """Test that admin use may confirm order.""" + order = factories.OrderFactory.create() + + url = reverse('subscription-order-confirm', args=[order.id]) + + res = admin_client.post(url) + assert res.status_code == status.HTTP_204_NO_CONTENT + + order.refresh_from_db() + assert order.acknowledged + assert order.confirmedby == admin_client.user + + +def test_order_confirm_user(auth_client): + """Test that default user may not confirm order.""" + order = factories.OrderFactory.create() + + url = reverse('subscription-order-confirm', args=[order.id]) + + res = auth_client.post(url) + assert res.status_code == status.HTTP_403_FORBIDDEN diff --git a/timed/subscription/tests/test_package.py b/timed/subscription/tests/test_package.py new file mode 100644 index 000000000..752c1f765 --- /dev/null +++ b/timed/subscription/tests/test_package.py @@ -0,0 +1,16 @@ +from django.core.urlresolvers import reverse +from rest_framework.status import HTTP_200_OK + +from timed.subscription.factories import PackageFactory + + +def test_subscription_package_list(auth_client): + PackageFactory.create() + + url = reverse('subscription-package-list') + + res = auth_client.get(url) + assert res.status_code == HTTP_200_OK + + json = res.json() + assert len(json['data']) == 1 diff --git a/timed/subscription/tests/test_subscription_project.py b/timed/subscription/tests/test_subscription_project.py new file mode 100644 index 000000000..0015ff0b8 --- /dev/null +++ b/timed/subscription/tests/test_subscription_project.py @@ -0,0 +1,67 @@ +from datetime import timedelta + +from django.core.urlresolvers import reverse +from rest_framework.status import HTTP_200_OK + +from timed.projects.factories import (BillingTypeFactory, CustomerFactory, + ProjectFactory, TaskFactory) +from timed.subscription.factories import OrderFactory, PackageFactory +from timed.tracking.factories import ReportFactory + + +def test_subscription_project_list(auth_client): + customer = CustomerFactory.create() + billing_type = BillingTypeFactory() + project = ProjectFactory.create( + billing_type=billing_type, + customer=customer + ) + PackageFactory.create(billing_type=billing_type) + # create spent hours + task = TaskFactory.create(project=project) + TaskFactory.create(project=project) + ReportFactory.create(task=task, duration=timedelta(hours=2)) + ReportFactory.create(task=task, duration=timedelta(hours=3)) + # not billable reports should not be included in spent hours + ReportFactory.create(not_billable=True, task=task, + duration=timedelta(hours=4)) + # project of same customer but without a billing type with packages + # should not appear + ProjectFactory.create(customer=customer) + + # create purchased time + OrderFactory.create( + project=project, + acknowledged=True, + duration=timedelta(hours=2) + ) + OrderFactory.create( + project=project, + acknowledged=True, + duration=timedelta(hours=4) + ) + + # report on different project should not be included in spent time + ReportFactory.create(duration=timedelta(hours=2)) + # not acknowledged order should not be included in purchased time + OrderFactory.create( + project=project, + duration=timedelta(hours=2) + ) + + url = reverse('subscription-project-list') + + res = auth_client.get( + url, + data={'customer': customer.id, + 'ordering': 'id'} + ) + assert res.status_code == HTTP_200_OK + + json = res.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(project.id) + + attrs = json['data'][0]['attributes'] + assert attrs['spent-time'] == '05:00:00' + assert attrs['purchased-time'] == '06:00:00' diff --git a/timed/subscription/urls.py b/timed/subscription/urls.py new file mode 100644 index 000000000..3c2621e79 --- /dev/null +++ b/timed/subscription/urls.py @@ -0,0 +1,14 @@ +from django.conf import settings +from rest_framework.routers import DefaultRouter + +from . import views + +r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) + +r.register(r'subscription-projects', views.SubscriptionProjectViewSet, + 'subscription-project') +r.register(r'subscription-packages', views.PackageViewSet, + 'subscription-package') +r.register(r'subscription-orders', views.OrderViewSet, 'subscription-order') + +urlpatterns = r.urls diff --git a/timed/subscription/views.py b/timed/subscription/views.py new file mode 100644 index 000000000..bcf3e0f0a --- /dev/null +++ b/timed/subscription/views.py @@ -0,0 +1,77 @@ +from rest_framework import (decorators, exceptions, mixins, permissions, + response, status, viewsets) + +from timed.projects.models import Project + +from . import filters, models, serializers + + +class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): + """ + Subscription specific project view. + + Subscription projects are not archived projects + which have a billing type with packages. + """ + + serializer_class = serializers.SubscriptionProjectSerializer + filter_class = filters.SubscriptionProjectFilter + ordering_fields = ( + 'name', + 'id' + ) + + def get_queryset(self): + return Project.objects.filter(archived=False, + billing_type__packages__isnull=False) + + +class PackageViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = serializers.PackageSerializer + filter_class = filters.PackageFilter + + def get_queryset(self): + return models.Package.objects.select_related( + 'billing_type' + ) + + +class OrderViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + serializer_class = serializers.OrderSerializer + filter_class = filters.OrderFilter + + @decorators.detail_route( + methods=['post'], + permission_classes=[ + permissions.IsAuthenticated, + permissions.IsAdminUser + ] + ) + def confirm(self, request, pk=None): + """ + Confirm order. + + Only allowed by staff members + """ + order = self.get_object() + order.acknowledged = True + order.confirmedby = request.user + order.save() + + return response.Response(status=status.HTTP_204_NO_CONTENT) + + def get_queryset(self): + return models.Order.objects.select_related( + 'project' + ) + + def perform_destroy(self, instance): + if instance.acknowledged: + # acknowledge orders may not be deleted + raise exceptions.PermissionDenied() + + instance.delete() diff --git a/timed/urls.py b/timed/urls.py index 2817294ea..ac55b9594 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -11,5 +11,6 @@ url(r'^api/v1/', include('timed.employment.urls')), url(r'^api/v1/', include('timed.projects.urls')), url(r'^api/v1/', include('timed.tracking.urls')), - url(r'^api/v1/', include('timed.reports.urls')) + url(r'^api/v1/', include('timed.reports.urls')), + url(r'^api/v1/', include('timed.subscription.urls')) ] From e8a70427b22fdbb742bfe5d1999cb1eba65078ef Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 16 Nov 2017 14:21:47 +0100 Subject: [PATCH 456/980] Use project filter on subscription project Filter are the same in both cases. --- timed/subscription/filters.py | 10 ---------- timed/subscription/views.py | 3 ++- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index 33bcdefba..8979e9550 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -1,18 +1,8 @@ from django_filters import FilterSet, NumberFilter -from timed.projects.models import Project - from . import models -class SubscriptionProjectFilter(FilterSet): - class Meta: - model = Project - fields = ( - 'billing_type', - ) - - class PackageFilter(FilterSet): class Meta: model = models.Package diff --git a/timed/subscription/views.py b/timed/subscription/views.py index bcf3e0f0a..e10ab0649 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,6 +1,7 @@ from rest_framework import (decorators, exceptions, mixins, permissions, response, status, viewsets) +from timed.projects.filters import ProjectFilterSet from timed.projects.models import Project from . import filters, models, serializers @@ -15,7 +16,7 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): """ serializer_class = serializers.SubscriptionProjectSerializer - filter_class = filters.SubscriptionProjectFilter + filter_class = ProjectFilterSet ordering_fields = ( 'name', 'id' From 0c2a7bfebbb6d427ffea66f4a8df534c5b8be974 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 16 Nov 2017 14:36:27 +0100 Subject: [PATCH 457/980] Configure duration field on subscription package in hours --- timed/subscription/admin.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py index 69ca28cd9..59c29f3e8 100644 --- a/timed/subscription/admin.py +++ b/timed/subscription/admin.py @@ -1,8 +1,21 @@ +from django import forms from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ + +from timed.forms import DurationInHoursField from . import models +class PackageForm(forms.ModelForm): + model = models.Package + duration = DurationInHoursField( + label=_('Duration in hours'), + required=True, + ) + + @admin.register(models.Package) class PackageAdmin(admin.ModelAdmin): list_display = ['billing_type', 'duration', 'price'] + form = PackageForm From ad280938b9f1815e66f978e49a35eb92112cc03d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 16 Nov 2017 15:37:47 +0100 Subject: [PATCH 458/980] Add customer filter to packages Needed so only packages will be shown to customer which he is allowed to see --- timed/subscription/filters.py | 3 +++ timed/subscription/tests/test_package.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index 8979e9550..f52070ec4 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -4,10 +4,13 @@ class PackageFilter(FilterSet): + customer = NumberFilter(field_name='billing_type__projects__customer') + class Meta: model = models.Package fields = ( 'billing_type', + 'customer', ) diff --git a/timed/subscription/tests/test_package.py b/timed/subscription/tests/test_package.py index 752c1f765..79ecf539b 100644 --- a/timed/subscription/tests/test_package.py +++ b/timed/subscription/tests/test_package.py @@ -1,6 +1,7 @@ from django.core.urlresolvers import reverse from rest_framework.status import HTTP_200_OK +from timed.projects.factories import ProjectFactory from timed.subscription.factories import PackageFactory @@ -14,3 +15,20 @@ def test_subscription_package_list(auth_client): json = res.json() assert len(json['data']) == 1 + + +def test_subscription_package_filter_customer(auth_client): + other_project = ProjectFactory.create() + PackageFactory.create(billing_type=other_project.billing_type) + + my_project = ProjectFactory.create() + package = PackageFactory.create(billing_type=my_project.billing_type) + + url = reverse('subscription-package-list') + + res = auth_client.get(url, data={'customer': my_project.customer.id}) + assert res.status_code == HTTP_200_OK + + json = res.json() + assert len(json['data']) == 1 + assert json['data'][0]['id'] == str(package.id) From 0d93fb21304eb1b21ec03422d30cf1c1854690b4 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Tue, 19 Dec 2017 14:47:30 +0100 Subject: [PATCH 459/980] fix subscription-project-detail route and add test --- .../tests/test_subscription_project.py | 16 +++++++++++++++- timed/subscription/views.py | 7 +++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/timed/subscription/tests/test_subscription_project.py b/timed/subscription/tests/test_subscription_project.py index 0015ff0b8..2bf497570 100644 --- a/timed/subscription/tests/test_subscription_project.py +++ b/timed/subscription/tests/test_subscription_project.py @@ -16,7 +16,7 @@ def test_subscription_project_list(auth_client): billing_type=billing_type, customer=customer ) - PackageFactory.create(billing_type=billing_type) + PackageFactory.create_batch(2, billing_type=billing_type) # create spent hours task = TaskFactory.create(project=project) TaskFactory.create(project=project) @@ -65,3 +65,17 @@ def test_subscription_project_list(auth_client): attrs = json['data'][0]['attributes'] assert attrs['spent-time'] == '05:00:00' assert attrs['purchased-time'] == '06:00:00' + + +def test_subscription_project_detail(auth_client): + billing_type = BillingTypeFactory() + project = ProjectFactory.create( + billing_type=billing_type + ) + PackageFactory.create_batch(2, billing_type=billing_type) + + url = reverse('subscription-project-detail', args=[project.id]) + res = auth_client.get(url) + assert res.status_code == HTTP_200_OK + json = res.json() + assert json['data']['id'] == str(project.id) diff --git a/timed/subscription/views.py b/timed/subscription/views.py index e10ab0649..6c049b30f 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,3 +1,4 @@ +from django.db.models import Count from rest_framework import (decorators, exceptions, mixins, permissions, response, status, viewsets) @@ -23,8 +24,10 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): ) def get_queryset(self): - return Project.objects.filter(archived=False, - billing_type__packages__isnull=False) + queryset = Project.objects.annotate( + count_packages=Count('billing_type__packages') + ) + return queryset.filter(archived=False, count_packages__gt=0) class PackageViewSet(viewsets.ReadOnlyModelViewSet): From bf182d5acd4fe87114d05b9c8e00a7b8e3a9c09c Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Tue, 19 Dec 2017 17:27:06 +0100 Subject: [PATCH 460/980] change relation project of subscription-order to subscription-project --- timed/subscription/serializers.py | 2 +- timed/subscription/tests/test_order.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index eba821a5d..b6a6533c8 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -76,7 +76,7 @@ class OrderSerializer(ModelSerializer): project = ResourceRelatedField(queryset=Project.objects.all()) included_serializers = { - 'project': 'timed.projects.serializers.ProjectSerializer', + 'project': 'timed.subscription.serializers.SubscriptionProjectSerializer', } class Meta: diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index 00fb01ebb..da8b46873 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -14,6 +14,9 @@ def test_order_list(auth_client): json = res.json() assert len(json['data']) == 1 + assert json['data'][0]['relationships']['project']['data']['type'] == ( + 'subscription-project' + ) def test_order_delete(auth_client): From 9004e21b335ea0da0f6f9f07926c97fe3a42b0e0 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Wed, 20 Dec 2017 11:01:15 +0100 Subject: [PATCH 461/980] fix line too long --- timed/subscription/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index b6a6533c8..2de7b2b18 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -76,7 +76,8 @@ class OrderSerializer(ModelSerializer): project = ResourceRelatedField(queryset=Project.objects.all()) included_serializers = { - 'project': 'timed.subscription.serializers.SubscriptionProjectSerializer', + 'project': ('timed.subscription.serializers' + '.SubscriptionProjectSerializer'), } class Meta: From 7dcc8ef70b973df2dbfc209d898fabc3438bcb97 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Thu, 21 Dec 2017 14:23:44 +0100 Subject: [PATCH 462/980] Fix subscription resource names --- timed/subscription/serializers.py | 6 +++--- timed/subscription/tests/test_order.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index 2de7b2b18..991498401 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -44,7 +44,7 @@ def get_spent_time(self, obj): class Meta: model = Project - resource_name = 'subscription-project' + resource_name = 'subscription-projects' fields = ( 'name', 'billing_type', @@ -64,7 +64,7 @@ class PackageSerializer(ModelSerializer): class Meta: model = Package - resource_name = 'subscription-package' + resource_name = 'subscription-packages' fields = ( 'duration', 'price', @@ -82,7 +82,7 @@ class OrderSerializer(ModelSerializer): class Meta: model = Order - resource_name = 'subscription-order' + resource_name = 'subscription-orders' fields = ( 'duration', 'acknowledged', diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index da8b46873..a86d27327 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -15,7 +15,7 @@ def test_order_list(auth_client): json = res.json() assert len(json['data']) == 1 assert json['data'][0]['relationships']['project']['data']['type'] == ( - 'subscription-project' + 'subscription-projects' ) From 313b6bee86f60b99e87ef8357f146ab78d240937 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Thu, 8 Mar 2018 08:59:53 +0100 Subject: [PATCH 463/980] re-add password field for customer as long as sysupport and the new cc need to run simultaneously, the old sysupport needs a password field for authentication. --- timed/projects/admin.py | 5 +++++ timed/subscription/admin.py | 16 ++++++++++++++++ timed/subscription/models.py | 14 ++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 19b73c2ef..e60f0f847 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -8,6 +8,8 @@ from timed.forms import DurationInHoursField from timed.projects import models from timed.redmine.admin import RedmineProjectInline +from timed.subscription.admin import CustomerPasswordInline + @admin.register(models.Customer) @@ -16,6 +18,9 @@ class CustomerAdmin(admin.ModelAdmin): list_display = ['name'] search_fields = ['name'] + inlines = [ + CustomerPasswordInline + ] def has_delete_permission(self, request, obj=None): return obj and not obj.projects.exists() diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py index 59c29f3e8..9af02bcbc 100644 --- a/timed/subscription/admin.py +++ b/timed/subscription/admin.py @@ -1,3 +1,5 @@ +import hashlib + from django import forms from django.contrib import admin from django.utils.translation import ugettext_lazy as _ @@ -19,3 +21,17 @@ class PackageForm(forms.ModelForm): class PackageAdmin(admin.ModelAdmin): list_display = ['billing_type', 'duration', 'price'] form = PackageForm + + +class CustomerPasswordForm(forms.ModelForm): + def save(self, commit=True): + password = self.cleaned_data.get('password') + if password is not None: + self.instance.password = hashlib.md5( + password.encode()).hexdigest() + return super().save(commit=commit) + + +class CustomerPasswordInline(admin.StackedInline): + form = CustomerPasswordForm + model = models.CustomerPassword diff --git a/timed/subscription/models.py b/timed/subscription/models.py index 5f3925f3a..2905ffab7 100644 --- a/timed/subscription/models.py +++ b/timed/subscription/models.py @@ -1,6 +1,7 @@ from django.conf import settings from django.db import models from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ from djmoney.models.fields import MoneyField @@ -33,3 +34,16 @@ class Order(models.Model): on_delete=models.SET_NULL, null=True, blank=True, related_name='orders_confirmed') + + +class CustomerPassword(models.Model): + """ + Password per customer used for login into SySupport portal. + + Password are only hashed with md5. This model will be obsolete + once customer center will go live. + """ + + customer = models.OneToOneField('projects.Customer') + password = models.CharField(_('password'), max_length=128, + null=True, blank=True) From a7220ba9ed48ecd6a24e63559e97450157244006 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Thu, 8 Mar 2018 09:40:42 +0100 Subject: [PATCH 464/980] add customer and orders to included_serializer we had a bug in ember where ember data could not sideload the orders for a project. customer is needed because without it, we would have to map Projects to SubscriptionProject to get the customer. Since we dont have access to Projects from cc/sysupport we need to access customer via SubscriptionProject. The ResourceRelatedFields are unnecessary since Django resolves this via Model relations. --- timed/projects/admin.py | 1 - timed/subscription/filters.py | 1 + .../migrations/0004_customerpassword.py | 25 +++++++++++++++++++ timed/subscription/serializers.py | 14 +++++------ 4 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 timed/subscription/migrations/0004_customerpassword.py diff --git a/timed/projects/admin.py b/timed/projects/admin.py index e60f0f847..714aa9b43 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -11,7 +11,6 @@ from timed.subscription.admin import CustomerPasswordInline - @admin.register(models.Customer) class CustomerAdmin(admin.ModelAdmin): """Customer admin view.""" diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index f52070ec4..fcb11bc90 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -16,6 +16,7 @@ class Meta: class OrderFilter(FilterSet): customer = NumberFilter(name='project__customer') + acknowledged = NumberFilter(field_name='acknowledged') class Meta: model = models.Order diff --git a/timed/subscription/migrations/0004_customerpassword.py b/timed/subscription/migrations/0004_customerpassword.py new file mode 100644 index 000000000..d62d9736a --- /dev/null +++ b/timed/subscription/migrations/0004_customerpassword.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.10 on 2018-03-06 14:59 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0006_auto_20171010_1423'), + ('subscription', '0003_auto_20170907_1151'), + ] + + operations = [ + migrations.CreateModel( + name='CustomerPassword', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(blank=True, max_length=128, null=True, verbose_name='password')), + ('customer', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='projects.Customer')), + ], + ), + ] diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index 991498401..f63cb7772 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -2,18 +2,16 @@ from django.db.models import Sum from django.utils.duration import duration_string -from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import (CharField, ModelSerializer, SerializerMethodField) -from timed.projects.models import BillingType, Project +from timed.projects.models import Project from timed.tracking.models import Report from .models import Order, Package class SubscriptionProjectSerializer(ModelSerializer): - billing_type = ResourceRelatedField(queryset=BillingType.objects.all()) purchased_time = SerializerMethodField(source='get_purchased_time') spent_time = SerializerMethodField(source='get_spent_time') @@ -39,7 +37,9 @@ def get_spent_time(self, obj): return duration_string(data['spent_time'] or timedelta()) included_serializers = { - 'billing_type': 'timed.projects.serializers.BillingTypeSerializer' + 'billing_type': 'timed.projects.serializers.BillingTypeSerializer', + 'customer': 'timed.projects.serializers.CustomerSerializer', + 'orders': 'timed.subscription.serializers.OrderSerializer' } class Meta: @@ -49,12 +49,13 @@ class Meta: 'name', 'billing_type', 'purchased_time', - 'spent_time' + 'spent_time', + 'customer', + 'orders' ) class PackageSerializer(ModelSerializer): - billing_type = ResourceRelatedField(queryset=BillingType.objects.all()) price = CharField() """CharField needed as it includes currency.""" @@ -73,7 +74,6 @@ class Meta: class OrderSerializer(ModelSerializer): - project = ResourceRelatedField(queryset=Project.objects.all()) included_serializers = { 'project': ('timed.subscription.serializers' From 10ab98137f4cd344cd85337c65602af3a6f5999b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Jun 2018 12:01:59 +0200 Subject: [PATCH 465/980] Do not drop customer passwords during migration --- .../migrations/0003_auto_20170907_1151.py | 7 ------ .../migrations/0004_customerpassword.py | 25 ------------------- 2 files changed, 32 deletions(-) delete mode 100644 timed/subscription/migrations/0004_customerpassword.py diff --git a/timed/subscription/migrations/0003_auto_20170907_1151.py b/timed/subscription/migrations/0003_auto_20170907_1151.py index 713600c3f..933c13316 100644 --- a/timed/subscription/migrations/0003_auto_20170907_1151.py +++ b/timed/subscription/migrations/0003_auto_20170907_1151.py @@ -38,10 +38,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RemoveField( - model_name='customerpassword', - name='customer', - ), migrations.RemoveField( model_name='subscriptionproject', name='project', @@ -61,9 +57,6 @@ class Migration(migrations.Migration): model_name='package', name='subscription', ), - migrations.DeleteModel( - name='CustomerPassword', - ), migrations.DeleteModel( name='Subscription', ), diff --git a/timed/subscription/migrations/0004_customerpassword.py b/timed/subscription/migrations/0004_customerpassword.py deleted file mode 100644 index d62d9736a..000000000 --- a/timed/subscription/migrations/0004_customerpassword.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.10 on 2018-03-06 14:59 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0006_auto_20171010_1423'), - ('subscription', '0003_auto_20170907_1151'), - ] - - operations = [ - migrations.CreateModel( - name='CustomerPassword', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(blank=True, max_length=128, null=True, verbose_name='password')), - ('customer', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='projects.Customer')), - ], - ), - ] From 7e26616503141d58fe503955594ef10b30fb6cdf Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sun, 17 Jun 2018 20:33:27 +0200 Subject: [PATCH 466/980] Update psycopg2 from 2.7.4 to 2.7.5 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9bf02395b..7cbd1f02d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.4.0 -psycopg2==2.7.4 +psycopg2==2.7.5 pytz==2018.4 pyexcel-webio==0.1.4 pyexcel-io==0.5.7 From ba4ad56ba202821c74b2594db591c5efb4050bc1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Jun 2018 09:05:26 +0200 Subject: [PATCH 467/980] Update requirements.txt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7cbd1f02d..d3da3500e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.4.0 -psycopg2==2.7.5 +psycopg2-binary==2.7.5 pytz==2018.4 pyexcel-webio==0.1.4 pyexcel-io==0.5.7 From ec1ecb1d22879a58146fa78a0af46ee30bf5fc58 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 20 Jun 2018 13:29:33 +0200 Subject: [PATCH 468/980] Update pytest from 3.6.1 to 3.6.2 --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index edd0d4734..fad5b3b7a 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.6.1 +pytest==3.6.2 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From 359193f390f9a9ccb3e8d19ae812904cf16121c0 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Thu, 21 Jun 2018 13:58:34 +0200 Subject: [PATCH 469/980] Bump to version 0.10.0 (#275) --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index ecc0ae4ba..9d1bb721b 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.9.3' +__version__ = '0.10.0' From a98a5d812813daa992843d31479a0dc55918bb44 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 22 Jun 2018 08:52:51 +0200 Subject: [PATCH 470/980] Use pyup release of pytest-django (#276) --- dev_requirements.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index fad5b3b7a..5020f905e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -15,7 +15,5 @@ pytest==3.6.2 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 -# once newer version than 3.1.2 has been released -# pytest-django can be imported through pypi again -git+https://github.com/pytest-dev/pytest-django@21492af +pytest-django==3.3.2 pytest-freezegun==0.2.0 From e07538670947209ee81b803475cf500bece05fd3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 22 Jun 2018 14:12:14 +0200 Subject: [PATCH 471/980] Allow passing on user as relationship (#277) Fixes 500 error as old django-json-api version was used where get_queryset was not allowed to be overwritten in ResourceRelatedField --- requirements.txt | 4 +++- timed/tracking/tests/test_attendance.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d3da3500e..f5ddc9a4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,9 @@ django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 -djangorestframework-jsonapi==2.4.0 +# use pyup version when following PR is released: +# https://github.com/django-json-api/django-rest-framework-json-api/pull/415 +git+https://github.com/django-json-api/django-rest-framework-json-api@ad3dd293ac40f08d3ad5dfb85914977f98186ab0 psycopg2-binary==2.7.5 pytz==2018.4 pyexcel-webio==0.1.4 diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index f3e6ca5c8..16001e7ca 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -60,6 +60,14 @@ def test_attendance_update(auth_client): 'id': attendance.id, 'attributes': { 'to-time': '15:00:00' + }, + 'relationships': { + 'user': { + 'data': { + 'id': auth_client.user.id, + 'type': 'users' + } + }, } } } From 34d5388ab006b01f5b63ef5f8b491a95e91bfe7e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 26 Jun 2018 09:11:15 +0200 Subject: [PATCH 472/980] Update django-environ from 0.4.4 to 0.4.5 (#278) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f5ddc9a4c..a39c9573f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ django-excel==0.0.10 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 -django-environ==0.4.4 +django-environ==0.4.5 rest_condition==1.0.3 django-money==0.14 python-redmine==2.1.1 From c0d46522a3b1488f69e58bbcf201637a13b01fb2 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Fri, 29 Jun 2018 14:58:35 +0200 Subject: [PATCH 473/980] fix package filter previously the filter returned too many packages because of a JOIN --- timed/subscription/filters.py | 9 ++++++++- timed/subscription/tests/test_package.py | 17 +++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index fcb11bc90..06113b1cb 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -1,10 +1,17 @@ from django_filters import FilterSet, NumberFilter +from timed.projects.models import Project + from . import models class PackageFilter(FilterSet): - customer = NumberFilter(field_name='billing_type__projects__customer') + customer = NumberFilter(method='filter_customer') + + def filter_customer(self, queryset, name, value): + billing_types = (Project.objects.filter(customer=value) + .values('billing_type')) + return queryset.filter(billing_type__in=billing_types) class Meta: model = models.Package diff --git a/timed/subscription/tests/test_package.py b/timed/subscription/tests/test_package.py index 79ecf539b..c88842337 100644 --- a/timed/subscription/tests/test_package.py +++ b/timed/subscription/tests/test_package.py @@ -1,7 +1,8 @@ from django.core.urlresolvers import reverse from rest_framework.status import HTTP_200_OK -from timed.projects.factories import ProjectFactory +from timed.projects.factories import (BillingTypeFactory, CustomerFactory, + ProjectFactory) from timed.subscription.factories import PackageFactory @@ -18,11 +19,15 @@ def test_subscription_package_list(auth_client): def test_subscription_package_filter_customer(auth_client): - other_project = ProjectFactory.create() - PackageFactory.create(billing_type=other_project.billing_type) - - my_project = ProjectFactory.create() - package = PackageFactory.create(billing_type=my_project.billing_type) + customer = CustomerFactory.create() + billing_type = BillingTypeFactory.create() + package = PackageFactory.create(billing_type=billing_type) + ProjectFactory.create(billing_type=billing_type, customer=customer) + + my_project = ProjectFactory.create( + billing_type=billing_type, + customer=customer + ) url = reverse('subscription-package-list') From c71dcf1360eeabf1cbc2a465339311b4a0d800a0 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Fri, 29 Jun 2018 18:52:11 +0200 Subject: [PATCH 474/980] remove unnecessary assignment from package test --- timed/subscription/tests/test_package.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/timed/subscription/tests/test_package.py b/timed/subscription/tests/test_package.py index c88842337..fef30778b 100644 --- a/timed/subscription/tests/test_package.py +++ b/timed/subscription/tests/test_package.py @@ -22,16 +22,14 @@ def test_subscription_package_filter_customer(auth_client): customer = CustomerFactory.create() billing_type = BillingTypeFactory.create() package = PackageFactory.create(billing_type=billing_type) - ProjectFactory.create(billing_type=billing_type, customer=customer) - - my_project = ProjectFactory.create( - billing_type=billing_type, - customer=customer + ProjectFactory.create_batch( + 2, + billing_type=billing_type, customer=customer ) url = reverse('subscription-package-list') - res = auth_client.get(url, data={'customer': my_project.customer.id}) + res = auth_client.get(url, data={'customer': customer.id}) assert res.status_code == HTTP_200_OK json = res.json() From f6025bc3b5bf5ae0a74f4639c2e7569a99908b87 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 2 Jul 2018 11:37:02 +0200 Subject: [PATCH 475/980] Update django from 1.11.13 to 1.11.14 (#282) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a39c9573f..5747e06fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.7.3 -django==1.11.13 # pyup: >=1.11,<1.12 +django==1.11.14 # pyup: >=1.11,<1.12 django-auth-ldap==1.6.1 django-filter==1.1.0 django-multiselectfield==0.1.8 From a049a0efaa16ea00aeddf7b1e70df04a20606eec Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Mon, 2 Jul 2018 14:53:35 +0200 Subject: [PATCH 476/980] fix indetation --- timed/subscription/filters.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index 06113b1cb..ba393632e 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -9,8 +9,11 @@ class PackageFilter(FilterSet): customer = NumberFilter(method='filter_customer') def filter_customer(self, queryset, name, value): - billing_types = (Project.objects.filter(customer=value) - .values('billing_type')) + billing_types = Project.objects.filter( + customer=value + ).values( + 'billing_type' + ) return queryset.filter(billing_type__in=billing_types) class Meta: From cc88b83cd75874e19a9a0f3532da2df63c463923 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Mon, 2 Jul 2018 15:35:03 +0200 Subject: [PATCH 477/980] add customer_visible flag to project (#281) * add customer_visible flag to project * indent correctly * add better comment --- .../0007_project_subscription_project.py | 31 ++++++++++++++++ timed/projects/models.py | 35 ++++++++++--------- .../tests/test_subscription_project.py | 8 +++-- timed/subscription/views.py | 7 ++-- 4 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 timed/projects/migrations/0007_project_subscription_project.py diff --git a/timed/projects/migrations/0007_project_subscription_project.py b/timed/projects/migrations/0007_project_subscription_project.py new file mode 100644 index 000000000..def453bf5 --- /dev/null +++ b/timed/projects/migrations/0007_project_subscription_project.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-29 13:20 +from __future__ import unicode_literals +from django.db.models import Count + +from django.db import migrations, models + +def migrate_projects(apps, schema_editor): + """Set subsctition_project on Projects with orders.""" + Project = apps.get_model('projects', 'Project') + visible_projects = Project.objects.annotate( + count_orders=Count('orders') + ).filter(archived=False, count_orders__gt=0) + visible_projects.update(customer_visible=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0006_auto_20171010_1423'), + ('subscription', '0003_auto_20170907_1151'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='customer_visible', + field=models.BooleanField(default=False), + ), + migrations.RunPython(migrate_projects), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index afc86d861..7d0f5a89c 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -66,22 +66,25 @@ class Project(models.Model): belongs to a customer. """ - name = models.CharField(max_length=255, db_index=True) - reference = models.CharField(max_length=255, db_index=True, - blank=True, null=True) - comment = models.TextField(blank=True) - archived = models.BooleanField(default=False) - estimated_time = models.DurationField(blank=True, null=True) - customer = models.ForeignKey('projects.Customer', - related_name='projects') - billing_type = models.ForeignKey(BillingType, on_delete=models.SET_NULL, - blank=True, null=True, - related_name='projects') - cost_center = models.ForeignKey(CostCenter, on_delete=models.SET_NULL, - blank=True, null=True, - related_name='projects') - reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, - related_name='reviews') + name = models.CharField(max_length=255, db_index=True) + reference = models.CharField(max_length=255, db_index=True, + blank=True, null=True) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) + estimated_time = models.DurationField(blank=True, null=True) + customer = models.ForeignKey('projects.Customer', + related_name='projects') + billing_type = models.ForeignKey(BillingType, + on_delete=models.SET_NULL, + blank=True, null=True, + related_name='projects') + cost_center = models.ForeignKey(CostCenter, + on_delete=models.SET_NULL, + blank=True, null=True, + related_name='projects') + reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, + related_name='reviews') + customer_visible = models.BooleanField(default=False) def __str__(self): """Represent the model as a string. diff --git a/timed/subscription/tests/test_subscription_project.py b/timed/subscription/tests/test_subscription_project.py index 2bf497570..eb15ba8dc 100644 --- a/timed/subscription/tests/test_subscription_project.py +++ b/timed/subscription/tests/test_subscription_project.py @@ -14,7 +14,8 @@ def test_subscription_project_list(auth_client): billing_type = BillingTypeFactory() project = ProjectFactory.create( billing_type=billing_type, - customer=customer + customer=customer, + customer_visible=True ) PackageFactory.create_batch(2, billing_type=billing_type) # create spent hours @@ -25,7 +26,7 @@ def test_subscription_project_list(auth_client): # not billable reports should not be included in spent hours ReportFactory.create(not_billable=True, task=task, duration=timedelta(hours=4)) - # project of same customer but without a billing type with packages + # project of same customer but without customer_visible set # should not appear ProjectFactory.create(customer=customer) @@ -70,7 +71,8 @@ def test_subscription_project_list(auth_client): def test_subscription_project_detail(auth_client): billing_type = BillingTypeFactory() project = ProjectFactory.create( - billing_type=billing_type + billing_type=billing_type, + customer_visible=True ) PackageFactory.create_batch(2, billing_type=billing_type) diff --git a/timed/subscription/views.py b/timed/subscription/views.py index 6c049b30f..65daa5d17 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,4 +1,3 @@ -from django.db.models import Count from rest_framework import (decorators, exceptions, mixins, permissions, response, status, viewsets) @@ -24,10 +23,10 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): ) def get_queryset(self): - queryset = Project.objects.annotate( - count_packages=Count('billing_type__packages') + return Project.objects.filter( + archived=False, + customer_visible=True ) - return queryset.filter(archived=False, count_packages__gt=0) class PackageViewSet(viewsets.ReadOnlyModelViewSet): From e5481dd54b45b65c6c1b98e325e865da64d134a5 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 5 Jul 2018 08:38:19 +0200 Subject: [PATCH 478/980] Update pytest from 3.6.2 to 3.6.3 (#283) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 5020f905e..1012b6951 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.6.2 +pytest==3.6.3 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From f8b163776dcbe5e4c78cb5955609c8eafc9bbb4d Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Fri, 6 Jul 2018 11:16:14 +0200 Subject: [PATCH 479/980] Bump to version 0.11.0 (#284) --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index 9d1bb721b..f323a57be 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.10.0' +__version__ = '0.11.0' From 4dced1f373d0e613530c117408c1b2b0cbd7b5d0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 11 Jul 2018 16:31:50 +0200 Subject: [PATCH 480/980] Update djangorestframework to version 2.5.0 --- requirements.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5747e06fd..d537c0aaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,7 @@ django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 -# use pyup version when following PR is released: -# https://github.com/django-json-api/django-rest-framework-json-api/pull/415 -git+https://github.com/django-json-api/django-rest-framework-json-api@ad3dd293ac40f08d3ad5dfb85914977f98186ab0 +djangorestframework-jsonapi==2.5.0 psycopg2-binary==2.7.5 pytz==2018.4 pyexcel-webio==0.1.4 From ae0c6887e5f01ea8797df23852633137f77d05d9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 12 Jul 2018 13:47:37 +0200 Subject: [PATCH 481/980] Update django-auth-ldap from 1.6.1 to 1.7.0 (#286) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d537c0aaa..0fa79c536 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ python-dateutil==2.7.3 django==1.11.14 # pyup: >=1.11,<1.12 -django-auth-ldap==1.6.1 +django-auth-ldap==1.7.0 django-filter==1.1.0 django-multiselectfield==0.1.8 djangorestframework==3.8.2 From 0d7d4552200708a7568efe7debaacb565d4a649e Mon Sep 17 00:00:00 2001 From: Yelinz <30687616+Yelinz@users.noreply.github.com> Date: Thu, 12 Jul 2018 16:35:18 +0200 Subject: [PATCH 482/980] Fixes invalid database host in docker-compose (#287) --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4642558bc..0d9270be9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,6 @@ services: depends_on: - db environment: - - DATABASE_URL=psql://timed:timed@db:5432/timed + - DJANGO_DATABASE_HOST=db - ENV=docker - STATIC_ROOT=/var/www/static From 2e2f7ed46645c38b0aaa4f00c7cbc11ba7513997 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 12 Jul 2018 17:14:16 +0200 Subject: [PATCH 483/980] Update pytz from 2018.4 to 2018.5 (#279) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0fa79c536..081a9aa9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.5.0 psycopg2-binary==2.7.5 -pytz==2018.4 +pytz==2018.5 pyexcel-webio==0.1.4 pyexcel-io==0.5.7 django-excel==0.0.10 From dffa4ec14c9e2cafd76850ac34a287858c7e235f Mon Sep 17 00:00:00 2001 From: Yelinz <30687616+Yelinz@users.noreply.github.com> Date: Tue, 17 Jul 2018 11:41:32 +0200 Subject: [PATCH 484/980] Add is_reviewer filter (#289) --- timed/employment/filters.py | 16 ++++++++++++---- timed/employment/tests/test_user.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 9c73df815..e4589b5dd 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,11 +1,12 @@ from datetime import date -from django.db.models import Value +from django.db.models import Exists, OuterRef, Value from django.db.models.functions import Coalesce from django_filters.rest_framework import (DateFilter, Filter, FilterSet, NumberFilter) from timed.employment import models +from timed.projects.models import Project class YearFilter(Filter): @@ -49,12 +50,19 @@ class Meta: class UserFilterSet(FilterSet): - active = NumberFilter(field_name='is_active') - supervisor = NumberFilter(field_name='supervisors') + active = NumberFilter(field_name='is_active') + supervisor = NumberFilter(field_name='supervisors') + is_reviewer = NumberFilter(method='filter_reviewers') + + def filter_reviewers(self, queryset, name, value): + reviewer = Project.objects.filter(reviewers=OuterRef('pk')) + return queryset.annotate( + is_reviewer=Exists(reviewer) + ).filter(is_reviewer=value) class Meta: model = models.User - fields = ['active', 'supervisor'] + fields = ['active', 'supervisor', 'is_reviewer'] class EmploymentFilterSet(FilterSet): diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 5a9b2940b..8fd1e9f26 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -7,6 +7,7 @@ from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, UserFactory) +from timed.projects.factories import ProjectFactory from timed.tracking.factories import AbsenceFactory, ReportFactory @@ -197,3 +198,16 @@ def test_user_transfer(superadmin_client): assert absence_credit.date == date(2018, 1, 1) assert absence_credit.days == -1 assert absence_credit.comment == 'Transfer 2017' + + +@pytest.mark.parametrize('value,expected', [(1, 1), (0, 4)]) +def test_user_is_reviewer_filter(auth_client, value, expected): + """Should filter users if they are a reviewer.""" + user = UserFactory.create() + project = ProjectFactory.create() + UserFactory.create_batch(3) + + project.reviewers.add(user) + + res = auth_client.get(reverse('user-list'), {'is_reviewer': value}) + assert len(res.json()['data']) == expected From 2b7fe0917ac35ef42eaf515a9cc556e4f030326c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 18 Jul 2018 08:45:31 +0200 Subject: [PATCH 485/980] Update django-money from 0.14 to 0.14.1 (#291) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 081a9aa9b..9077cd6de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.5 rest_condition==1.0.3 -django-money==0.14 +django-money==0.14.1 python-redmine==2.1.1 From 1807c26c70561165789f6355fe14e111bfd18baf Mon Sep 17 00:00:00 2001 From: Yelinz <30687616+Yelinz@users.noreply.github.com> Date: Wed, 18 Jul 2018 09:14:05 +0200 Subject: [PATCH 486/980] Add is_supervisor filter (#290) --- timed/employment/filters.py | 23 ++++++++++++++--------- timed/employment/tests/test_user.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index e4589b5dd..518f2480d 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,12 +1,12 @@ from datetime import date -from django.db.models import Exists, OuterRef, Value +from django.db.models import Value from django.db.models.functions import Coalesce from django_filters.rest_framework import (DateFilter, Filter, FilterSet, NumberFilter) from timed.employment import models -from timed.projects.models import Project +from timed.employment.models import User class YearFilter(Filter): @@ -52,17 +52,22 @@ class Meta: class UserFilterSet(FilterSet): active = NumberFilter(field_name='is_active') supervisor = NumberFilter(field_name='supervisors') - is_reviewer = NumberFilter(method='filter_reviewers') + is_reviewer = NumberFilter(method='filter_is_reviewer') + is_supervisor = NumberFilter(method='filter_is_supervisor') - def filter_reviewers(self, queryset, name, value): - reviewer = Project.objects.filter(reviewers=OuterRef('pk')) - return queryset.annotate( - is_reviewer=Exists(reviewer) - ).filter(is_reviewer=value) + def filter_is_reviewer(self, queryset, name, value): + if value: + return queryset.filter(pk__in=User.objects.all_reviewers()) + return queryset.exclude(pk__in=User.objects.all_reviewers()) + + def filter_is_supervisor(self, queryset, name, value): + if value: + return queryset.filter(pk__in=User.objects.all_supervisors()) + return queryset.exclude(pk__in=User.objects.all_supervisors()) class Meta: model = models.User - fields = ['active', 'supervisor', 'is_reviewer'] + fields = ['active', 'supervisor', 'is_reviewer', 'is_supervisor'] class EmploymentFilterSet(FilterSet): diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 8fd1e9f26..b37842e77 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -211,3 +211,15 @@ def test_user_is_reviewer_filter(auth_client, value, expected): res = auth_client.get(reverse('user-list'), {'is_reviewer': value}) assert len(res.json()['data']) == expected + + +@pytest.mark.parametrize('value,expected', [(1, 1), (0, 5)]) +def test_user_is_supervisor_filter(auth_client, value, expected): + """Should filter useres if they are a supervisor.""" + users = UserFactory.create_batch(2) + UserFactory.create_batch(3) + + auth_client.user.supervisees.add(*users) + + res = auth_client.get(reverse('user-list'), {'is_supervisor': value}) + assert len(res.json()['data']) == expected From b044dfea66c9f1d7350b778a9f0fd923ad4c6c79 Mon Sep 17 00:00:00 2001 From: Yelinz <30687616+Yelinz@users.noreply.github.com> Date: Fri, 20 Jul 2018 15:48:45 +0200 Subject: [PATCH 487/980] Update django-filter to 2.0.0 (#292) --- requirements.txt | 2 +- timed/employment/filters.py | 9 +++------ timed/employment/views.py | 16 ++++++++-------- timed/projects/filters.py | 4 ++++ timed/projects/views.py | 6 +++--- timed/reports/views.py | 18 +++++++++--------- timed/subscription/filters.py | 2 +- timed/subscription/views.py | 6 +++--- timed/tracking/filters.py | 11 ++++------- timed/tracking/views.py | 10 +++++----- 10 files changed, 41 insertions(+), 43 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9077cd6de..917ec9cb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ python-dateutil==2.7.3 django==1.11.14 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 -django-filter==1.1.0 +django-filter==2.0.0 django-multiselectfield==0.1.8 djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 518f2480d..ee792b176 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -2,6 +2,7 @@ from django.db.models import Value from django.db.models.functions import Coalesce +from django_filters.constants import EMPTY_VALUES from django_filters.rest_framework import (DateFilter, Filter, FilterSet, NumberFilter) @@ -13,13 +14,9 @@ class YearFilter(Filter): """Filter to filter a queryset by year.""" def filter(self, qs, value): - """Filter the queryset. + if value in EMPTY_VALUES: + return qs - :param QuerySet qs: The queryset to filter - :param str value: The year to filter to - :return: The filtered queryset - :rtype: QuerySet - """ return qs.filter(**{ '%s__year' % self.field_name: value }) diff --git a/timed/employment/views.py b/timed/employment/views.py index 0bee4edf2..d0b572555 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -40,7 +40,7 @@ class UserViewSet(ModelViewSet): ] serializer_class = serializers.UserSerializer - filter_class = filters.UserFilterSet + filterset_class = filters.UserFilterSet search_fields = ('username', 'first_name', 'last_name') def get_queryset(self): @@ -104,7 +104,7 @@ class WorktimeBalanceViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Calculate worktime for different user on different dates.""" serializer_class = serializers.WorktimeBalanceSerializer - filter_class = filters.WorktimeBalanceFilterSet + filterset_class = filters.WorktimeBalanceFilterSet def _extract_date(self): """ @@ -175,7 +175,7 @@ class AbsenceBalanceViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Calculate absence balance for different user on different dates.""" serializer_class = serializers.AbsenceBalanceSerializer - filter_class = filters.AbsenceBalanceFilterSet + filterset_class = filters.AbsenceBalanceFilterSet def _extract_date(self): """ @@ -277,7 +277,7 @@ def get_queryset(self): class EmploymentViewSet(ModelViewSet): serializer_class = serializers.EmploymentSerializer ordering = ('-end_date',) - filter_class = filters.EmploymentFilterSet + filterset_class = filters.EmploymentFilterSet permission_classes = [ # super user can add/read overtime credits C(IsAuthenticated) & C(IsSuperUser) | @@ -318,7 +318,7 @@ class PublicHolidayViewSet(ReadOnlyModelViewSet): """Public holiday view set.""" serializer_class = serializers.PublicHolidaySerializer - filter_class = filters.PublicHolidayFilterSet + filterset_class = filters.PublicHolidayFilterSet ordering = ('date',) def get_queryset(self): @@ -337,14 +337,14 @@ class AbsenceTypeViewSet(ReadOnlyModelViewSet): queryset = models.AbsenceType.objects.all() serializer_class = serializers.AbsenceTypeSerializer - filter_class = filters.AbsenceTypeFilterSet + filterset_class = filters.AbsenceTypeFilterSet ordering = ('name',) class AbsenceCreditViewSet(ModelViewSet): """Absence type view set.""" - filter_class = filters.AbsenceCreditFilterSet + filterset_class = filters.AbsenceCreditFilterSet serializer_class = serializers.AbsenceCreditSerializer permission_classes = [ # super user can add/read absence credits @@ -377,7 +377,7 @@ def get_queryset(self): class OvertimeCreditViewSet(ModelViewSet): """Absence type view set.""" - filter_class = filters.OvertimeCreditFilterSet + filterset_class = filters.OvertimeCreditFilterSet serializer_class = serializers.OvertimeCreditSerializer permission_classes = [ # super user can add/read overtime credits diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 0c9464541..a0725f51d 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -2,6 +2,7 @@ from datetime import date, timedelta from django.db.models import Count +from django_filters.constants import EMPTY_VALUES from django_filters.rest_framework import Filter, FilterSet, NumberFilter from timed.projects import models @@ -62,6 +63,9 @@ def filter(self, qs, value): :return: The filtered queryset :rtype: QuerySet """ + if value in EMPTY_VALUES: + return qs + user = self.parent.request.user from_date = date.today() - timedelta(days=60) diff --git a/timed/projects/views.py b/timed/projects/views.py index 233f0f54f..d990e8a03 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -10,7 +10,7 @@ class CustomerViewSet(ReadOnlyModelViewSet): """Customer view set.""" serializer_class = serializers.CustomerSerializer - filter_class = filters.CustomerFilterSet + filterset_class = filters.CustomerFilterSet ordering = 'name' def get_queryset(self): @@ -44,7 +44,7 @@ class ProjectViewSet(PrefetchForIncludesHelperMixin, ReadOnlyModelViewSet): """Project view set.""" serializer_class = serializers.ProjectSerializer - filter_class = filters.ProjectFilterSet + filterset_class = filters.ProjectFilterSet ordering_fields = ('customer__name', 'name',) ordering = 'name' queryset = models.Project.objects.all() @@ -65,7 +65,7 @@ class TaskViewSet(ReadOnlyModelViewSet): """Task view set.""" serializer_class = serializers.TaskSerializer - filter_class = filters.TaskFilterSet + filterset_class = filters.TaskFilterSet ordering = 'name' def get_queryset(self): diff --git a/timed/reports/views.py b/timed/reports/views.py index 3fe216156..130efac90 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -22,7 +22,7 @@ class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" serializer_class = serializers.YearStatisticSerializer - filter_class = ReportFilterSet + filterset_class = ReportFilterSet ordering_fields = ('year', 'duration') ordering = ('year', ) @@ -38,7 +38,7 @@ class MonthStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Month statistics calculates total reported time per month.""" serializer_class = serializers.MonthStatisticSerializer - filter_class = ReportFilterSet + filterset_class = ReportFilterSet ordering_fields = ('year', 'month', 'duration') ordering = ('year', 'month') @@ -57,7 +57,7 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Customer statistics calculates total reported time per customer.""" serializer_class = serializers.CustomerStatisticSerializer - filter_class = ReportFilterSet + filterset_class = ReportFilterSet ordering_fields = ('task__project__customer__name', 'duration') ordering = ('task__project__customer__name', ) @@ -75,7 +75,7 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Project statistics calculates total reported time per project.""" serializer_class = serializers.ProjectStatisticSerializer - filter_class = ReportFilterSet + filterset_class = ReportFilterSet ordering_fields = ('task__project__name', 'duration') ordering = ('task__project__name', ) @@ -97,7 +97,7 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Task statistics calculates total reported time per task.""" serializer_class = serializers.TaskStatisticSerializer - filter_class = ReportFilterSet + filterset_class = ReportFilterSet ordering_fields = ('task__name', 'duration') ordering = ('task__name', ) @@ -119,7 +119,7 @@ class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """User calculates total reported time per user.""" serializer_class = serializers.UserStatisticSerializer - filter_class = ReportFilterSet + filterset_class = ReportFilterSet ordering_fields = ('user__username', 'duration') ordering = ('user__username', ) @@ -141,7 +141,7 @@ class WorkReportViewSet(GenericViewSet): in several projects work reports will be returned as zip. """ - filter_class = ReportFilterSet + filterset_class = ReportFilterSet ordering = ReportViewSet.ordering ordering_fields = ReportViewSet.ordering_fields @@ -158,8 +158,8 @@ def get_queryset(self): ) def _parse_query_params(self, queryset, request): - """Parse query params by using filter_class.""" - fltr = self.filter_class( + """Parse query params by using filterset_class.""" + fltr = self.filterset_class( request.query_params, queryset=queryset, request=request) diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index ba393632e..d62189115 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -25,7 +25,7 @@ class Meta: class OrderFilter(FilterSet): - customer = NumberFilter(name='project__customer') + customer = NumberFilter(field_name='project__customer') acknowledged = NumberFilter(field_name='acknowledged') class Meta: diff --git a/timed/subscription/views.py b/timed/subscription/views.py index 65daa5d17..b54772314 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -16,7 +16,7 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): """ serializer_class = serializers.SubscriptionProjectSerializer - filter_class = ProjectFilterSet + filterset_class = ProjectFilterSet ordering_fields = ( 'name', 'id' @@ -31,7 +31,7 @@ def get_queryset(self): class PackageViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.PackageSerializer - filter_class = filters.PackageFilter + filterset_class = filters.PackageFilter def get_queryset(self): return models.Package.objects.select_related( @@ -45,7 +45,7 @@ class OrderViewSet(mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = serializers.OrderSerializer - filter_class = filters.OrderFilter + filterset_class = filters.OrderFilter @decorators.detail_route( methods=['post'], diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 02f208194..bb80f1706 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -3,6 +3,7 @@ from functools import wraps from django.db.models import Q +from django_filters.constants import EMPTY_VALUES from django_filters.rest_framework import (BaseInFilter, DateFilter, Filter, FilterSet, NumberFilter) @@ -18,13 +19,9 @@ def boolean_filter(func): """ @wraps(func) def wrapper(self, qs, value): - """Wrap the initial function. + if value in EMPTY_VALUES: + return qs - :param QuerySet qs: The queryset to filter - :param str value: The value to cast - :return: The original function - :rtype: function - """ value = value.lower() not in ('1', 'true', 'yes') return func(self, qs, value) @@ -99,7 +96,7 @@ class ReportFilterSet(FilterSet): editable = NumberFilter(method='filter_editable') not_billable = NumberFilter(field_name='not_billable') verified = NumberFilter( - name='verified_by_id', lookup_expr='isnull', exclude=True + field_name='verified_by_id', lookup_expr='isnull', exclude=True ) reviewer = NumberFilter(field_name='task__project__reviewers') verifier = NumberFilter(field_name='verified_by') diff --git a/timed/tracking/views.py b/timed/tracking/views.py index e82a2f6a7..f375a80b5 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -22,7 +22,7 @@ class ActivityViewSet(ModelViewSet): """Activity view set.""" serializer_class = serializers.ActivitySerializer - filter_class = filters.ActivityFilterSet + filterset_class = filters.ActivityFilterSet def get_queryset(self): """Filter the queryset by the user of the request. @@ -46,7 +46,7 @@ class ActivityBlockViewSet(ModelViewSet): """Activity view set.""" serializer_class = serializers.ActivityBlockSerializer - filter_class = filters.ActivityBlockFilterSet + filterset_class = filters.ActivityBlockFilterSet def get_queryset(self): """Filter the queryset by the user of the request. @@ -65,7 +65,7 @@ class AttendanceViewSet(ModelViewSet): """Attendance view set.""" serializer_class = serializers.AttendanceSerializer - filter_class = filters.AttendanceFilterSet + filterset_class = filters.AttendanceFilterSet def get_queryset(self): """Filter the queryset by the user of the request. @@ -84,7 +84,7 @@ class ReportViewSet(ModelViewSet): """Report view set.""" serializer_class = serializers.ReportSerializer - filter_class = filters.ReportFilterSet + filterset_class = filters.ReportFilterSet permission_classes = [ # superuser may edit all reports but not delete C(IsSuperUser) & ~C(IsDeleteOnly) | @@ -277,7 +277,7 @@ class AbsenceViewSet(ModelViewSet): """Absence view set.""" serializer_class = serializers.AbsenceSerializer - filter_class = filters.AbsenceFilterSet + filterset_class = filters.AbsenceFilterSet permission_classes = [ # superuser can change all but not delete From 7083d222779ad276359c7f3ca2bfb3fee49f64de Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 23 Jul 2018 16:25:27 +0200 Subject: [PATCH 488/980] Update django-money from 0.14.1 to 0.14.2 (#293) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 917ec9cb4..e7a5feebe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.5 rest_condition==1.0.3 -django-money==0.14.1 +django-money==0.14.2 python-redmine==2.1.1 From 43cf8e7f238031d0cc972784aed3b8bcef4d6f81 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 27 Jul 2018 08:38:07 +0200 Subject: [PATCH 489/980] Update pytest-django from 3.3.2 to 3.3.3 (#294) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 1012b6951..b2d74e267 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -15,5 +15,5 @@ pytest==3.6.3 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 -pytest-django==3.3.2 +pytest-django==3.3.3 pytest-freezegun==0.2.0 From fef1de099a67f17ede33f7583e84aec107e220b3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 30 Jul 2018 08:58:30 +0200 Subject: [PATCH 490/980] Update pytest from 3.6.3 to 3.6.4 (#295) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index b2d74e267..e288a7842 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.6.3 +pytest==3.6.4 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From aeca5b9f68c38f30b8b7c5e5f83a14e6357073eb Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 31 Jul 2018 08:40:28 +0200 Subject: [PATCH 491/980] Update pytest from 3.6.4 to 3.7.0 (#296) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index e288a7842..bb5d63a21 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.6.4 +pytest==3.7.0 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From 177f645ae1e9eda0a9daf8d7194ea9346f59fcf2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 2 Aug 2018 08:46:41 +0200 Subject: [PATCH 492/980] Update django from 1.11.14 to 1.11.15 (#298) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e7a5feebe..a941d7103 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.7.3 -django==1.11.14 # pyup: >=1.11,<1.12 +django==1.11.15 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 django-multiselectfield==0.1.8 From 1ffd0e51aebc51118fa4a3a95b42eb3c592f50c5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 Aug 2018 14:39:24 +0200 Subject: [PATCH 493/980] Update docker python to 3.6 and uwsgi to 2.0.17 (#297) Extract uwsgi into requirements.txt so it is updated with pyup automatically --- .travis.yml | 2 +- Dockerfile | 6 ++---- requirements.txt | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 979a54583..53d54e067 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: python python: - - "3.5.2" + - "3.6" services: - postgresql diff --git a/Dockerfile b/Dockerfile index abec5dc13..7a31802a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,9 @@ -FROM python:3.5.4 +FROM python:3.6 RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ - python-pip \ -&& rm -rf /var/lib/apt/lists/* \ -&& pip install uwsgi==2.0.15 +&& rm -rf /var/lib/apt/lists/* ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static diff --git a/requirements.txt b/requirements.txt index a941d7103..0b4457cf1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ django-environ==0.4.5 rest_condition==1.0.3 django-money==0.14.2 python-redmine==2.1.1 +uwsgi==2.0.17 From 2b0b9c65693a1de82b2e0fc8bf97c3fa1c993d20 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 2 Aug 2018 14:47:01 +0200 Subject: [PATCH 494/980] Update uwsgi from 2.0.17 to 2.0.17.1 (#299) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0b4457cf1..0256d9755 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ django-environ==0.4.5 rest_condition==1.0.3 django-money==0.14.2 python-redmine==2.1.1 -uwsgi==2.0.17 +uwsgi==2.0.17.1 From 47399a44ac689a6ea9da9009b14229762e3fc88e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 3 Aug 2018 08:49:53 +0200 Subject: [PATCH 495/980] Update pytest from 3.7.0 to 3.7.1 (#300) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index bb5d63a21..2a2b0956a 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.7.0 +pytest==3.7.1 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From 841ce1fdb1acfab90550eba0a8a2a2fa1fda0e91 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 17 Aug 2018 00:20:43 -0700 Subject: [PATCH 496/980] Update pytest-django from 3.3.3 to 3.4.1 (#303) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 2a2b0956a..c1f9acd1d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -15,5 +15,5 @@ pytest==3.7.1 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 -pytest-django==3.3.3 +pytest-django==3.4.1 pytest-freezegun==0.2.0 From 402e491f11354d64df51fc67e1f338734192e779 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 17 Aug 2018 01:19:31 -0700 Subject: [PATCH 497/980] Update pyexcel-io from 0.5.7 to 0.5.8 (#302) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0256d9755..32b725815 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.5.0 psycopg2-binary==2.7.5 pytz==2018.5 pyexcel-webio==0.1.4 -pyexcel-io==0.5.7 +pyexcel-io==0.5.8 django-excel==0.0.10 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.6 From 001d1e870b850a57151c1dabe3f6a30b73cdbd92 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 20 Aug 2018 00:18:05 -0700 Subject: [PATCH 498/980] Update pytest from 3.7.1 to 3.7.2 (#304) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index c1f9acd1d..e2034856d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.7.1 +pytest==3.7.2 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From c9cd77dd5724813a4d53821135699517d3741679 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 21 Aug 2018 04:53:06 -0700 Subject: [PATCH 499/980] Update pytest-django from 3.4.1 to 3.4.2 (#305) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index e2034856d..d581c649e 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -15,5 +15,5 @@ pytest==3.7.2 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 -pytest-django==3.4.1 +pytest-django==3.4.2 pytest-freezegun==0.2.0 From 5f83497222a19482c3724476e34ec9c1034b387a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 23 Aug 2018 23:41:49 -0700 Subject: [PATCH 500/980] Update pyexcel-io from 0.5.8 to 0.5.9 (#306) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 32b725815..40f42697a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.5.0 psycopg2-binary==2.7.5 pytz==2018.5 pyexcel-webio==0.1.4 -pyexcel-io==0.5.8 +pyexcel-io==0.5.9 django-excel==0.0.10 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.6 From df7f909f138f6e002b9a5537d00c024272bfb5d2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 26 Aug 2018 23:39:57 -0700 Subject: [PATCH 501/980] Update pytest from 3.7.2 to 3.7.3 (#307) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index d581c649e..a0bfb33f1 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.7.2 +pytest==3.7.3 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From 8531e9b4ddbe87b4b80b8f98395f88f4e06fc1ff Mon Sep 17 00:00:00 2001 From: Yelin Zhang <30687616+Yelinz@users.noreply.github.com> Date: Mon, 27 Aug 2018 09:31:45 +0200 Subject: [PATCH 502/980] Replace activity blocks with activities (#301) --- timed/tracking/factories.py | 24 +-- timed/tracking/filters.py | 13 +- .../migrations/0006_add_activity_time.py | 32 ++++ .../0007_migrate_activity_blocks.py | 31 ++++ .../migrations/0008_delete_activity_blocks.py | 18 ++ timed/tracking/models.py | 49 ++--- timed/tracking/serializers.py | 50 +----- timed/tracking/tests/test_activity.py | 63 ++++++- timed/tracking/tests/test_activity_block.py | 168 ------------------ timed/tracking/urls.py | 1 - timed/tracking/views.py | 23 +-- 11 files changed, 169 insertions(+), 303 deletions(-) create mode 100644 timed/tracking/migrations/0006_add_activity_time.py create mode 100644 timed/tracking/migrations/0007_migrate_activity_blocks.py create mode 100644 timed/tracking/migrations/0008_delete_activity_blocks.py delete mode 100644 timed/tracking/tests/test_activity_block.py diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 4138b2897..e87737b92 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -54,22 +54,12 @@ class Meta: class ActivityFactory(DjangoModelFactory): """Activity factory.""" - comment = Faker('sentence') - task = SubFactory('timed.projects.factories.TaskFactory') - date = Faker('date') - user = SubFactory('timed.employment.factories.UserFactory') - - class Meta: - """Meta informations for the activity block factory.""" - - model = models.Activity - - -class ActivityBlockFactory(DjangoModelFactory): - """Activity block factory.""" - - activity = SubFactory(ActivityFactory) - from_time = Faker('time_object') + comment = Faker('sentence') + task = SubFactory('timed.projects.factories.TaskFactory') + date = Faker('date') + user = SubFactory('timed.employment.factories.UserFactory') + from_time = Faker('time_object') + transferred = False @lazy_attribute def from_time(self): @@ -85,7 +75,7 @@ def to_time(self): class Meta: """Meta informations for the activity block factory.""" - model = models.ActivityBlock + model = models.Activity class AbsenceFactory(DjangoModelFactory): diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index bb80f1706..8b7cda1d1 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -46,8 +46,7 @@ def filter(self, qs, value): :rtype: QuerySet """ return qs.filter( - blocks__isnull=False, - blocks__to_time__exact=None + to_time__exact=None ).distinct() @@ -64,16 +63,6 @@ class Meta: fields = ['active', 'day'] -class ActivityBlockFilterSet(FilterSet): - """Filter set for the activity blocks endpoint.""" - - class Meta: - """Meta information for the activity block filter set.""" - - model = models.ActivityBlock - fields = ['activity'] - - class AttendanceFilterSet(FilterSet): """Filter set for the attendance endpoint.""" diff --git a/timed/tracking/migrations/0006_add_activity_time.py b/timed/tracking/migrations/0006_add_activity_time.py new file mode 100644 index 000000000..48824ac07 --- /dev/null +++ b/timed/tracking/migrations/0006_add_activity_time.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-08-10 13:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0005_remove_absence_duration'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='transferred', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='activity', + name='from_time', + field=models.TimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='activity', + name='to_time', + field=models.TimeField(blank=True, null=True), + ), + ] diff --git a/timed/tracking/migrations/0007_migrate_activity_blocks.py b/timed/tracking/migrations/0007_migrate_activity_blocks.py new file mode 100644 index 000000000..0582a5f72 --- /dev/null +++ b/timed/tracking/migrations/0007_migrate_activity_blocks.py @@ -0,0 +1,31 @@ +from django.db import migrations + + +def migrate_blocks(apps, schema_editor): + Activity = apps.get_model('tracking', 'Activity') + for activity in Activity.objects.all(): + for i, block in enumerate(activity.blocks.all()): + if i != 0: + Activity.objects.create( + from_time=block.from_time, + to_time=block.to_time, + comment=activity.comment, + date=activity.date, + task=activity.task, + user=activity.user + ).save() + else: + activity.from_time = block.from_time + activity.to_time = block.to_time + activity.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0006_add_activity_time'), + ] + + operations = [ + migrations.RunPython(migrate_blocks), + ] diff --git a/timed/tracking/migrations/0008_delete_activity_blocks.py b/timed/tracking/migrations/0008_delete_activity_blocks.py new file mode 100644 index 000000000..d0abecd76 --- /dev/null +++ b/timed/tracking/migrations/0008_delete_activity_blocks.py @@ -0,0 +1,18 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0007_migrate_activity_blocks'), + ] + + operations = [ + migrations.RemoveField( + model_name='activityblock', + name='activity', + ), + migrations.DeleteModel( + name='ActivityBlock', + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 2ef078167..366862c95 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -4,35 +4,26 @@ from django.conf import settings from django.db import models -from django.db.models import F, Sum class Activity(models.Model): """Activity model. - An activity represents multiple timeblocks in which a user worked on a + An activity represents a timeblock in which a user worked on a certain task. """ - comment = models.TextField(blank=True) - date = models.DateField() - task = models.ForeignKey('projects.Task', - null=True, - blank=True, - related_name='activities') - user = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name='activities') - - @property - def duration(self): - """Calculate the total duration of this activity. - - :return: The total duration - :rtype: datetime.timedelta - """ - return self.blocks.all().aggregate( - duration=Sum(F('to_time') - F('from_time')) - ).get('duration') + from_time = models.TimeField() + to_time = models.TimeField(blank=True, null=True) + comment = models.TextField(blank=True) + date = models.DateField() + transferred = models.BooleanField(default=False) + task = models.ForeignKey('projects.Task', + null=True, + blank=True, + related_name='activities') + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='activities') def __str__(self): """Represent the model as a string. @@ -49,22 +40,6 @@ class Meta: indexes = [models.Index(fields=['date'])] -class ActivityBlock(models.Model): - """Activity block model. - - An activity block is a timeblock of an activity. - """ - - activity = models.ForeignKey('tracking.Activity', - related_name='blocks') - from_time = models.TimeField() - to_time = models.TimeField(blank=True, null=True) - - def __str__(self): - return '{0} ({1} - {2})'.format(self.activity, self.from_time, - self.to_time) - - class Attendance(models.Model): """Attendance model. diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 5d6d5a437..f29a6377b 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -7,8 +7,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api import relations, serializers from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import (DurationField, - ModelSerializer, Serializer, +from rest_framework_json_api.serializers import (ModelSerializer, Serializer, SerializerMethodField, ValidationError) @@ -22,56 +21,27 @@ class ActivitySerializer(ModelSerializer): """Activity serializer.""" - duration = DurationField(read_only=True) user = CurrentUserResourceRelatedField() - task = ResourceRelatedField(queryset=Task.objects.all(), - allow_null=True, - required=False) - blocks = ResourceRelatedField(read_only=True, many=True) included_serializers = { - 'blocks': 'timed.tracking.serializers.ActivityBlockSerializer', 'task': 'timed.projects.serializers.TaskSerializer', 'user': 'timed.employment.serializers.UserSerializer', } - class Meta: - """Meta information for the activity serializer.""" - - model = models.Activity - fields = [ - 'comment', - 'date', - 'duration', - 'user', - 'task', - 'blocks', - ] - - -class ActivityBlockSerializer(ModelSerializer): - """Activity block serializer.""" - - activity = ResourceRelatedField(queryset=models.Activity.objects.all()) - - included_serializers = { - 'activity': 'timed.tracking.serializers.ActivitySerializer', - } - def validate(self, data): """Validate the activity block. - Ensure that a user can only have one activity with an active block + Ensure that a user can only have one activity which doesn't end before it started. """ instance = self.instance from_time = data.get('from_time', instance and instance.from_time) to_time = data.get('to_time', instance and instance.to_time) - user = instance and instance.activity.user or data.get('activity').user + user = instance and instance.user or data['user'] # validate that there is only one active activity - blocks = models.ActivityBlock.objects.filter(activity__user=user) - if blocks.filter(to_time__isnull=True) and to_time is None: + activity = models.Activity.objects.filter(user=user) + if activity.filter(to_time__isnull=True) and to_time is None: raise ValidationError( _('A user can only have one active activity') ) @@ -85,14 +55,10 @@ def validate(self, data): return data class Meta: - """Meta information for the activity block serializer.""" + """Meta information for the activity serializer.""" - model = models.ActivityBlock - fields = [ - 'activity', - 'from_time', - 'to_time', - ] + model = models.Activity + fields = '__all__' class AttendanceSerializer(ModelSerializer): diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 63ab3f5c0..eb1bfe991 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -1,10 +1,10 @@ -from datetime import date, timedelta +from datetime import date, time, timedelta from django.core.urlresolvers import reverse from rest_framework import status from timed.projects.factories import TaskFactory -from timed.tracking.factories import ActivityBlockFactory, ActivityFactory +from timed.tracking.factories import ActivityFactory def test_activity_list(auth_client): @@ -40,6 +40,7 @@ def test_activity_create(auth_client): 'type': 'activities', 'id': None, 'attributes': { + 'from-time': '08:00', 'date': '2017-01-01', 'comment': 'Test activity' }, @@ -107,8 +108,7 @@ def test_activity_delete(auth_client): def test_activity_list_filter_active(auth_client): user = auth_client.user ActivityFactory.create(user=user) - activity = ActivityFactory.create(user=user) - ActivityBlockFactory.create(activity=activity, to_time=None) + activity = ActivityFactory.create(user=user, to_time=None) url = reverse('activity-list') @@ -141,6 +141,7 @@ def test_activity_create_no_task(auth_client): 'type': 'activities', 'id': None, 'attributes': { + 'from-time': '08:00', 'date': '2017-01-01', 'comment': 'Test activity' }, @@ -158,3 +159,57 @@ def test_activity_create_no_task(auth_client): json = response.json() assert json['data']['relationships']['task']['data'] is None + + +def test_activity_active_unique(auth_client): + """Should not be able to have two active blocks.""" + ActivityFactory.create(user=auth_client.user, to_time=None) + + data = { + 'data': { + 'type': 'activities', + 'id': None, + 'attributes': { + 'from-time': '08:00', + 'date': '2017-01-01', + 'comment': 'Test activity' + } + } + } + + url = reverse('activity-list') + + res = auth_client.post(url, data) + + assert res.status_code == status.HTTP_400_BAD_REQUEST + json = res.json() + assert json['errors'][0]['detail'] == ( + 'A user can only have one active activity' + ) + + +def test_activity_to_before_from(auth_client): + """Test that to is not before from.""" + activity = ActivityFactory.create(user=auth_client.user, + from_time=time(7, 30), + to_time=None) + + data = { + 'data': { + 'type': 'activities', + 'id': activity.id, + 'attributes': { + 'to-time': '07:00', + } + } + } + + url = reverse('activity-detail', args=[activity.id]) + + res = auth_client.patch(url, data) + + assert res.status_code == status.HTTP_400_BAD_REQUEST + json = res.json() + assert json['errors'][0]['detail'] == ( + 'An activity block may not end before it starts.' + ) diff --git a/timed/tracking/tests/test_activity_block.py b/timed/tracking/tests/test_activity_block.py deleted file mode 100644 index f2c5e0bd4..000000000 --- a/timed/tracking/tests/test_activity_block.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Tests for the activity blocks endpoint.""" - -from datetime import time - -from django.core.urlresolvers import reverse -from rest_framework import status - -from timed.tracking.factories import ActivityBlockFactory, ActivityFactory - - -def test_activity_block_list(auth_client): - user = auth_client.user - activity = ActivityFactory.create(user=user) - block = ActivityBlockFactory.create(activity=activity) - ActivityBlockFactory.create() - - url = reverse('activity-block-list') - response = auth_client.get(url) - assert response.status_code == status.HTTP_200_OK - - json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(block.id) - - -def test_activity_block_detail(auth_client): - user = auth_client.user - activity = ActivityFactory.create(user=user) - block = ActivityBlockFactory.create(activity=activity) - - url = reverse('activity-block-detail', args=[block.id]) - - response = auth_client.get(url) - assert response.status_code == status.HTTP_200_OK - - -def test_activity_block_create(auth_client): - user = auth_client.user - activity = ActivityFactory.create(user=user) - - data = { - 'data': { - 'type': 'activity-blocks', - 'id': None, - 'attributes': { - 'from-time': '08:00' - }, - 'relationships': { - 'activity': { - 'data': { - 'type': 'activities', - 'id': activity.id - } - } - } - } - } - - url = reverse('activity-block-list') - - response = auth_client.post(url, data) - assert response.status_code == status.HTTP_201_CREATED - - json = response.json() - assert not json['data']['attributes']['from-time'] == '08:00' - assert json['data']['attributes']['to-time'] is None - - -def test_activity_block_update(auth_client): - user = auth_client.user - activity = ActivityFactory.create(user=user) - block = ActivityBlockFactory.create(activity=activity, to_time=time(10, 0)) - - data = { - 'data': { - 'type': 'activity-blocks', - 'id': block.id, - 'attributes': { - 'to-time': '23:59:00', - 'from-time': '10:00:00' - } - } - } - - url = reverse('activity-block-detail', args=[block.id]) - response = auth_client.patch(url, data) - assert response.status_code == status.HTTP_200_OK - - json = response.json() - assert ( - json['data']['attributes']['to-time'] == - data['data']['attributes']['to-time'] - ) - - -def test_activity_block_delete(auth_client): - user = auth_client.user - activity = ActivityFactory.create(user=user) - block = ActivityBlockFactory.create(activity=activity) - - url = reverse('activity-block-detail', args=[block.id]) - - response = auth_client.delete(url) - assert response.status_code == status.HTTP_204_NO_CONTENT - - -def test_activity_block_active_unique(auth_client): - """Should not be able to have two active blocks.""" - activity = ActivityFactory.create(user=auth_client.user) - block = ActivityBlockFactory.create(to_time=None, activity=activity) - - data = { - 'data': { - 'type': 'activity-blocks', - 'id': None, - 'attributes': { - 'from-time': '08:00', - }, - 'relationships': { - 'activity': { - 'data': { - 'type': 'activities', - 'id': block.activity.id - } - } - } - } - } - - url = reverse('activity-block-list') - - res = auth_client.post(url, data) - - assert res.status_code == status.HTTP_400_BAD_REQUEST - json = res.json() - assert json['errors'][0]['detail'] == ( - 'A user can only have one active activity' - ) - - -def test_activity_block_to_before_from(auth_client): - """Test that to is not before from.""" - activity = ActivityFactory.create(user=auth_client.user) - block = ActivityBlockFactory.create( - from_time=time(7, 30), - to_time=None, - activity=activity - ) - - data = { - 'data': { - 'type': 'activity-blocks', - 'id': block.id, - 'attributes': { - 'to-time': '07:00', - } - } - } - - url = reverse('activity-block-detail', args=[block.id]) - - res = auth_client.patch(url, data) - - assert res.status_code == status.HTTP_400_BAD_REQUEST - json = res.json() - assert json['errors'][0]['detail'] == ( - 'An activity block may not end before it starts.' - ) diff --git a/timed/tracking/urls.py b/timed/tracking/urls.py index 563d1d936..fa1811968 100644 --- a/timed/tracking/urls.py +++ b/timed/tracking/urls.py @@ -8,7 +8,6 @@ r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) r.register(r'activities', views.ActivityViewSet, 'activity') -r.register(r'activity-blocks', views.ActivityBlockViewSet, 'activity-block') r.register(r'attendances', views.AttendanceViewSet, 'attendance') r.register(r'reports', views.ReportViewSet, 'report') r.register(r'absences', views.AbsenceViewSet, 'absence') diff --git a/timed/tracking/views.py b/timed/tracking/views.py index f375a80b5..831602b33 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -30,9 +30,7 @@ def get_queryset(self): :return: The filtered activities :rtype: QuerySet """ - return models.Activity.objects.prefetch_related( - 'blocks' - ).select_related( + return models.Activity.objects.select_related( 'task', 'user', 'task__project', @@ -42,25 +40,6 @@ def get_queryset(self): ) -class ActivityBlockViewSet(ModelViewSet): - """Activity view set.""" - - serializer_class = serializers.ActivityBlockSerializer - filterset_class = filters.ActivityBlockFilterSet - - def get_queryset(self): - """Filter the queryset by the user of the request. - - :return: The filtered activity blocks - :rtype: QuerySet - """ - return models.ActivityBlock.objects.select_related( - 'activity' - ).filter( - activity__user=self.request.user - ) - - class AttendanceViewSet(ModelViewSet): """Attendance view set.""" From 39146310e56da28fd31a3b8a50aed6f89b34d899 Mon Sep 17 00:00:00 2001 From: Yelin Zhang <30687616+Yelinz@users.noreply.github.com> Date: Wed, 29 Aug 2018 14:43:25 +0200 Subject: [PATCH 503/980] Remove activity foreign key from report (#309) --- .../migrations/0009_remove_report_activity.py | 19 +++++++++++++++++++ timed/tracking/models.py | 5 ----- timed/tracking/views.py | 3 +-- 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 timed/tracking/migrations/0009_remove_report_activity.py diff --git a/timed/tracking/migrations/0009_remove_report_activity.py b/timed/tracking/migrations/0009_remove_report_activity.py new file mode 100644 index 000000000..100e3bc5d --- /dev/null +++ b/timed/tracking/migrations/0009_remove_report_activity.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-08-29 08:51 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0008_delete_activity_blocks'), + ] + + operations = [ + migrations.RemoveField( + model_name='report', + name='activity', + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 366862c95..75a781e7c 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -82,11 +82,6 @@ class Report(models.Model): review = models.BooleanField(default=False) not_billable = models.BooleanField(default=False) task = models.ForeignKey('projects.Task', related_name='reports') - activity = models.ForeignKey(Activity, - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='reports') user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='reports') verified_by = models.ForeignKey(settings.AUTH_USER_MODEL, diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 831602b33..6e7125fc8 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -247,8 +247,7 @@ def get_queryset(self): """ return models.Report.objects.select_related( 'task', - 'user', - 'activity' + 'user' ).select_related('task__project', 'task__project__customer') From 9fa036e6ceeaa16dbf7735ffbf607b7981f3485c Mon Sep 17 00:00:00 2001 From: Yelin Zhang <30687616+Yelinz@users.noreply.github.com> Date: Wed, 29 Aug 2018 15:12:45 +0200 Subject: [PATCH 504/980] Protect transferred activities (#308) --- timed/permissions.py | 7 ++++++ timed/tracking/tests/test_activity.py | 31 +++++++++++++++++++++++++++ timed/tracking/views.py | 9 ++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/timed/permissions.py b/timed/permissions.py index 48d330aa8..b3a3946b7 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -91,3 +91,10 @@ def has_permission(self, request, view): def has_object_permission(self, request, view, obj): return self.has_permission(request, view) + + +class IsNotTransferred(BasePermission): + """Allows access only to not transferred objects.""" + + def has_object_permission(self, request, view, obj): + return not obj.transferred diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index eb1bfe991..1f9d9f134 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -213,3 +213,34 @@ def test_activity_to_before_from(auth_client): assert json['errors'][0]['detail'] == ( 'An activity block may not end before it starts.' ) + + +def test_activity_not_editable(auth_client): + """Test that transferred activities are read only.""" + activity = ActivityFactory.create(user=auth_client.user, transferred=True) + + data = { + 'data': { + 'type': 'activities', + 'id': activity.id, + 'attributes': { + 'comment': 'Changed Comment', + } + } + } + + url = reverse('activity-detail', args=[activity.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_activity_retrievable_not_editable(auth_client): + """Test that transferred activities are still retrievable.""" + activity = ActivityFactory.create(user=auth_client.user, transferred=True) + + url = reverse('activity-detail', args=[ + activity.id + ]) + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 6e7125fc8..e319e7db3 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -11,8 +11,8 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from timed.permissions import (IsAuthenticated, IsDeleteOnly, IsOwner, - IsReadOnly, IsReviewer, IsSuperUser, +from timed.permissions import (IsAuthenticated, IsDeleteOnly, IsNotTransferred, + IsOwner, IsReadOnly, IsReviewer, IsSuperUser, IsSupervisor, IsUnverified) from timed.serializers import AggregateObject from timed.tracking import filters, models, serializers @@ -23,6 +23,11 @@ class ActivityViewSet(ModelViewSet): serializer_class = serializers.ActivitySerializer filterset_class = filters.ActivityFilterSet + permission_classes = [ + # users may not change transferred activities + C(IsAuthenticated) & C(IsNotTransferred) | + C(IsAuthenticated) & C(IsReadOnly) + ] def get_queryset(self): """Filter the queryset by the user of the request. From f8ccc73c95fa3ff2c1b3ba91b91d4ed583a20027 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 29 Aug 2018 23:41:07 -0700 Subject: [PATCH 505/980] Update pytest from 3.7.3 to 3.7.4 (#310) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index a0bfb33f1..51520df1b 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.7.3 +pytest==3.7.4 pytest-cov==2.5.1 pytest-env==0.6.2 pytest-mock==1.10.0 From f831625766c2fcf01e3c1f45dbe8994900d83a44 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 30 Aug 2018 23:41:28 -0700 Subject: [PATCH 506/980] Update pyexcel-io from 0.5.9 to 0.5.9.1 (#311) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 40f42697a..8eeb0934e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.5.0 psycopg2-binary==2.7.5 pytz==2018.5 pyexcel-webio==0.1.4 -pyexcel-io==0.5.9 +pyexcel-io==0.5.9.1 django-excel==0.0.10 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.6 From bb5faa0fdf602a84af9fdadc81a5fc8fd53fb236 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 4 Sep 2018 00:29:17 -0700 Subject: [PATCH 507/980] Update pytest-cov from 2.5.1 to 2.6.0 (#313) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 51520df1b..1416ce1e9 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -12,7 +12,7 @@ ipdb==0.11 isort==4.3.4 mockldap==0.3.0 pytest==3.7.4 -pytest-cov==2.5.1 +pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 pytest-django==3.4.2 From 9b576df3674007d4244c2d223f52a2415764ad69 Mon Sep 17 00:00:00 2001 From: Yelin Zhang <30687616+Yelinz@users.noreply.github.com> Date: Tue, 4 Sep 2018 09:35:26 +0200 Subject: [PATCH 508/980] Add review and not-billable flag to activity (#314) --- timed/tracking/factories.py | 14 ++++++----- .../migrations/0010_auto_20180904_0818.py | 25 +++++++++++++++++++ timed/tracking/models.py | 24 ++++++++++-------- 3 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 timed/tracking/migrations/0010_auto_20180904_0818.py diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index e87737b92..ae2aa2917 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -54,12 +54,14 @@ class Meta: class ActivityFactory(DjangoModelFactory): """Activity factory.""" - comment = Faker('sentence') - task = SubFactory('timed.projects.factories.TaskFactory') - date = Faker('date') - user = SubFactory('timed.employment.factories.UserFactory') - from_time = Faker('time_object') - transferred = False + comment = Faker('sentence') + task = SubFactory('timed.projects.factories.TaskFactory') + date = Faker('date') + user = SubFactory('timed.employment.factories.UserFactory') + from_time = Faker('time_object') + transferred = False + review = False + not_billable = False @lazy_attribute def from_time(self): diff --git a/timed/tracking/migrations/0010_auto_20180904_0818.py b/timed/tracking/migrations/0010_auto_20180904_0818.py new file mode 100644 index 000000000..e254fb3cd --- /dev/null +++ b/timed/tracking/migrations/0010_auto_20180904_0818.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-09-04 06:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0009_remove_report_activity'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='not_billable', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='activity', + name='review', + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 75a781e7c..a0837820f 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -13,17 +13,19 @@ class Activity(models.Model): certain task. """ - from_time = models.TimeField() - to_time = models.TimeField(blank=True, null=True) - comment = models.TextField(blank=True) - date = models.DateField() - transferred = models.BooleanField(default=False) - task = models.ForeignKey('projects.Task', - null=True, - blank=True, - related_name='activities') - user = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name='activities') + from_time = models.TimeField() + to_time = models.TimeField(blank=True, null=True) + comment = models.TextField(blank=True) + date = models.DateField() + transferred = models.BooleanField(default=False) + review = models.BooleanField(default=False) + not_billable = models.BooleanField(default=False) + task = models.ForeignKey('projects.Task', + null=True, + blank=True, + related_name='activities') + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='activities') def __str__(self): """Represent the model as a string. From 30bb209b8519c820fea0b87d46fbc3dfd185eb0a Mon Sep 17 00:00:00 2001 From: Yelin Zhang Date: Tue, 4 Sep 2018 11:30:44 +0200 Subject: [PATCH 509/980] Change activity to accept patch when active --- timed/tracking/serializers.py | 3 ++- timed/tracking/tests/test_activity.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index f29a6377b..f44b0620d 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -41,7 +41,8 @@ def validate(self, data): # validate that there is only one active activity activity = models.Activity.objects.filter(user=user) - if activity.filter(to_time__isnull=True) and to_time is None: + if (activity.filter(to_time__isnull=True) and + to_time is None and instance is None): raise ValidationError( _('A user can only have one active activity') ) diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 1f9d9f134..68dfdb22f 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -244,3 +244,27 @@ def test_activity_retrievable_not_editable(auth_client): response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK + + +def test_activity_patch_no_reject(auth_client): + activity = ActivityFactory.create(user=auth_client.user, to_time=None) + + data = { + 'data': { + 'type': 'activities', + 'id': activity.id, + 'attributes': { + 'from-time': '08:00', + 'comment': 'Changed Comment', + } + } + } + + url = reverse('activity-detail', args=[activity.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert ( + json['data']['attributes']['comment'] == + data['data']['attributes']['comment'] + ) From be7189cbdade004d0b3c7506d0b86656ce5cea3b Mon Sep 17 00:00:00 2001 From: Yelin Zhang Date: Tue, 4 Sep 2018 14:45:25 +0200 Subject: [PATCH 510/980] Adjust activity validation --- timed/tracking/serializers.py | 3 ++- timed/tracking/tests/test_activity.py | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index f44b0620d..138c2fb2e 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -42,7 +42,8 @@ def validate(self, data): # validate that there is only one active activity activity = models.Activity.objects.filter(user=user) if (activity.filter(to_time__isnull=True) and - to_time is None and instance is None): + to_time is None and not + (instance is not None and to_time is instance.to_time)): raise ValidationError( _('A user can only have one active activity') ) diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 68dfdb22f..42f3a2e95 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -246,7 +246,7 @@ def test_activity_retrievable_not_editable(auth_client): assert response.status_code == status.HTTP_200_OK -def test_activity_patch_no_reject(auth_client): +def test_activity_active_update(auth_client): activity = ActivityFactory.create(user=auth_client.user, to_time=None) data = { @@ -268,3 +268,23 @@ def test_activity_patch_no_reject(auth_client): json['data']['attributes']['comment'] == data['data']['attributes']['comment'] ) + + +def test_activity_set_to_time_none(auth_client): + ActivityFactory.create(user=auth_client.user, to_time=None) + activity = ActivityFactory.create(user=auth_client.user) + + data = { + 'data': { + 'type': 'activities', + 'id': activity.id, + 'attributes': { + 'to-time': None, + } + } + } + + url = reverse('activity-detail', args=[activity.id]) + + res = auth_client.patch(url, data) + assert res.status_code == status.HTTP_400_BAD_REQUEST From 991f3a5866fe3eb849d0bde97d526e2af7fe3a40 Mon Sep 17 00:00:00 2001 From: Yelin Zhang Date: Tue, 4 Sep 2018 15:30:40 +0200 Subject: [PATCH 511/980] Split the activity unique active validation --- timed/tracking/serializers.py | 18 ++++++++++++------ timed/tracking/tests/test_activity.py | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 138c2fb2e..c37305f47 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -39,14 +39,20 @@ def validate(self, data): to_time = data.get('to_time', instance and instance.to_time) user = instance and instance.user or data['user'] + def validate_running_activity(): + if activity.filter(to_time__isnull=True).exists(): + raise ValidationError( + _('A user can only have one active activity') + ) + # validate that there is only one active activity activity = models.Activity.objects.filter(user=user) - if (activity.filter(to_time__isnull=True) and - to_time is None and not - (instance is not None and to_time is instance.to_time)): - raise ValidationError( - _('A user can only have one active activity') - ) + # if the request creates a new activity + if instance is None and to_time is None: + validate_running_activity() + # if the request mutates an existsting activity + if instance and instance.to_time and to_time is None: + validate_running_activity() # validate that to is not before from if to_time is not None and to_time < from_time: diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 42f3a2e95..a6683df6a 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -271,8 +271,8 @@ def test_activity_active_update(auth_client): def test_activity_set_to_time_none(auth_client): - ActivityFactory.create(user=auth_client.user, to_time=None) activity = ActivityFactory.create(user=auth_client.user) + ActivityFactory.create(user=auth_client.user, to_time=None) data = { 'data': { From e83cfeaa1932989a853f19882c968113cce16e4a Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Fri, 31 Aug 2018 13:34:58 +0200 Subject: [PATCH 512/980] Add arguments cc and message --- .../commands/notify_reviewers_unverified.py | 21 ++++++++++++++++--- .../mail/notify_reviewers_unverified.txt | 2 ++ .../tests/test_notify_reviewers_unverified.py | 17 +++++++++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index 0bad13220..4f6c53d6d 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -48,10 +48,24 @@ def add_arguments(self, parser): dest='offset', help='Period will end today minus given offset.' ) + parser.add_argument( + '--message', + type=str, + dest='message', + help='Additional message to send if there are unverified reports' + ) + parser.add_argument( + '--cc', + type=str, + dest='cc', + help='Email address where to send a cc' + ) def handle(self, *args, **options): months = options['months'] offset = options['offset'] + message = options['message'] + cc = options['cc'] today = date.today() # -1 as we also skip today @@ -60,7 +74,7 @@ def handle(self, *args, **options): start = end - relativedelta(months=months, days=-1) reports = self._get_unverified_reports(start, end) - self._notify_reviewers(start, end, reports) + self._notify_reviewers(start, end, reports, message, cc) def _get_unverified_reports(self, start, end): """ @@ -80,7 +94,7 @@ def _get_unverified_reports(self, start, end): return queryset - def _notify_reviewers(self, start, end, reports): + def _notify_reviewers(self, start, end, reports, message, cc): """Notify reviewers on their unverified reports.""" User = get_user_model() reviewers = User.objects.all_reviewers().filter(email__isnull=False) @@ -95,13 +109,14 @@ def _notify_reviewers(self, start, end, reports): # we need start and end date in system format 'start': str(start), 'end': str(end), + 'message': message, 'reviewer': reviewer, 'protocol': settings.HOST_PROTOCOL, 'domain': settings.HOST_DOMAIN, }, using='text' ) - mails.append((subject, body, from_email, [reviewer.email])) + mails.append((subject, body, from_email, [reviewer.email, cc])) if len(mails) > 0: send_mass_mail(mails) diff --git a/timed/reports/templates/mail/notify_reviewers_unverified.txt b/timed/reports/templates/mail/notify_reviewers_unverified.txt index 63f827e2d..7a48df704 100644 --- a/timed/reports/templates/mail/notify_reviewers_unverified.txt +++ b/timed/reports/templates/mail/notify_reviewers_unverified.txt @@ -1,3 +1,5 @@ There are unverified reports which need your attention. +{{message}} + Go to <{{protocol}}://{{domain}}/analysis?fromDate={{start}}&toDate={{end}}&reviewer={{reviewer.id}}&editable=1> diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 8150c1417..763f45f22 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -27,14 +27,27 @@ def test_notify_reviewers(db, mailoutbox): ReportFactory.create(date=date(2017, 7, 1), task=task_no_work, verified_by=reviewer_no_work) - call_command('notify_reviewers_unverified') + call_command( + 'notify_reviewers_unverified', + '--cc=example@example.com', + '--message=This is a test' + ) # checks - assert len(mailoutbox) == 1 mail = mailoutbox[0] + cc = mail.to[-1] + mail.to.pop() + + for item in mail.body.split("\n"): + if "test" in item: + msg = item.strip() + + assert len(mailoutbox) == 1 assert mail.to == [reviewer_work.email] url = ( 'http://localhost:4200/analysis?fromDate=2017-07-01&' 'toDate=2017-07-31&reviewer=%d&editable=1' ) % reviewer_work.id assert url in mail.body + assert msg == 'This is a test' + assert cc == 'example@example.com' From c1a3d40295a7c4b5f178ae78b49a90c317844371 Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Fri, 31 Aug 2018 13:44:25 +0200 Subject: [PATCH 513/980] Replace double quotes with single quotes for flake8 check --- timed/reports/tests/test_notify_reviewers_unverified.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 763f45f22..469a75742 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -38,8 +38,8 @@ def test_notify_reviewers(db, mailoutbox): cc = mail.to[-1] mail.to.pop() - for item in mail.body.split("\n"): - if "test" in item: + for item in mail.body.split('\n'): + if 'test' in item: msg = item.strip() assert len(mailoutbox) == 1 From 819127e4c3820eb37851329f78b499483db3cdb5 Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Fri, 31 Aug 2018 15:21:05 +0200 Subject: [PATCH 514/980] Change type of cc to list, add default to message, add cc field and edit assertion of message in test --- .../commands/notify_reviewers_unverified.py | 25 +++++++++++++------ .../tests/test_notify_reviewers_unverified.py | 11 ++------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index 4f6c53d6d..fbe3b8903 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -7,6 +7,7 @@ from django.core.management.base import BaseCommand from django.db.models import Count from django.template.loader import render_to_string +from django.core.mail import get_connection, EmailMessage from timed.tracking.models import Report @@ -50,15 +51,17 @@ def add_arguments(self, parser): ) parser.add_argument( '--message', + default='', type=str, dest='message', help='Additional message to send if there are unverified reports' ) parser.add_argument( '--cc', - type=str, + default=[], + action='append', dest='cc', - help='Email address where to send a cc' + help='List of email addresses where to send a cc' ) def handle(self, *args, **options): @@ -100,7 +103,7 @@ def _notify_reviewers(self, start, end, reports, message, cc): reviewers = User.objects.all_reviewers().filter(email__isnull=False) subject = '[Timed] Verification of reports' from_email = settings.DEFAULT_FROM_EMAIL - mails = [] + connection = get_connection() for reviewer in reviewers: if reports.filter(task__project__reviewers=reviewer).exists(): @@ -116,7 +119,15 @@ def _notify_reviewers(self, start, end, reports, message, cc): }, using='text' ) - mails.append((subject, body, from_email, [reviewer.email, cc])) - - if len(mails) > 0: - send_mass_mail(mails) + messages = [ + EmailMessage( + subject=subject, + body=body, + from_email=from_email, + to=[reviewer.email], + cc=cc, + connection=connection + ) + ] + if len(messages) > 0: + connection.send_messages(messages) diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 469a75742..9a7ee1dcf 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -35,13 +35,6 @@ def test_notify_reviewers(db, mailoutbox): # checks mail = mailoutbox[0] - cc = mail.to[-1] - mail.to.pop() - - for item in mail.body.split('\n'): - if 'test' in item: - msg = item.strip() - assert len(mailoutbox) == 1 assert mail.to == [reviewer_work.email] url = ( @@ -49,5 +42,5 @@ def test_notify_reviewers(db, mailoutbox): 'toDate=2017-07-31&reviewer=%d&editable=1' ) % reviewer_work.id assert url in mail.body - assert msg == 'This is a test' - assert cc == 'example@example.com' + assert 'This is a test' in mail.body + assert mail.cc == ['example@example.com'] From 4b527e628e4a9876db2a13f3f42df20f522fb63a Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Fri, 31 Aug 2018 16:36:34 +0200 Subject: [PATCH 515/980] Add tests for all call variants and check if cc is empty --- .../commands/notify_reviewers_unverified.py | 27 ++++++----- .../tests/test_notify_reviewers_unverified.py | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index fbe3b8903..f66d64dbf 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -3,11 +3,10 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth import get_user_model -from django.core.mail import send_mass_mail +from django.core.mail import get_connection, EmailMessage from django.core.management.base import BaseCommand from django.db.models import Count from django.template.loader import render_to_string -from django.core.mail import get_connection, EmailMessage from timed.tracking.models import Report @@ -104,6 +103,7 @@ def _notify_reviewers(self, start, end, reports, message, cc): subject = '[Timed] Verification of reports' from_email = settings.DEFAULT_FROM_EMAIL connection = get_connection() + messages = [] for reviewer in reviewers: if reports.filter(task__project__reviewers=reviewer).exists(): @@ -119,15 +119,18 @@ def _notify_reviewers(self, start, end, reports, message, cc): }, using='text' ) - messages = [ - EmailMessage( - subject=subject, - body=body, - from_email=from_email, - to=[reviewer.email], - cc=cc, - connection=connection - ) - ] + message = EmailMessage( + subject=subject, + body=body, + from_email=from_email, + to=[reviewer.email], + connection=connection + ) + + if cc: + message.cc = cc + + messages.append(message) + if len(messages) > 0: connection.send_messages(messages) diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 9a7ee1dcf..603f03df9 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -44,3 +44,49 @@ def test_notify_reviewers(db, mailoutbox): assert url in mail.body assert 'This is a test' in mail.body assert mail.cc == ['example@example.com'] + + call_command('notify_reviewers_unverified',) + + # checks + mail = mailoutbox[1] + assert len(mailoutbox) == 2 + assert mail.to == [reviewer_work.email] + url = ( + 'http://localhost:4200/analysis?fromDate=2017-07-01&' + 'toDate=2017-07-31&reviewer=%d&editable=1' + ) % reviewer_work.id + assert url in mail.body + assert mail.cc == [] + + call_command( + 'notify_reviewers_unverified', + '--message=This is a test' + ) + + # checks + mail = mailoutbox[2] + assert len(mailoutbox) == 3 + assert mail.to == [reviewer_work.email] + url = ( + 'http://localhost:4200/analysis?fromDate=2017-07-01&' + 'toDate=2017-07-31&reviewer=%d&editable=1' + ) % reviewer_work.id + assert url in mail.body + assert 'This is a test' in mail.body + assert mail.cc == [] + + call_command( + 'notify_reviewers_unverified', + '--cc=example@example.com' + ) + + # checks + mail = mailoutbox[3] + assert len(mailoutbox) == 4 + assert mail.to == [reviewer_work.email] + url = ( + 'http://localhost:4200/analysis?fromDate=2017-07-01&' + 'toDate=2017-07-31&reviewer=%d&editable=1' + ) % reviewer_work.id + assert url in mail.body + assert mail.cc == ['example@example.com'] From 126615da88ebfe5cbb60558157c993151a159b1f Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Fri, 31 Aug 2018 16:53:05 +0200 Subject: [PATCH 516/980] Change import order --- .../reports/management/commands/notify_reviewers_unverified.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index f66d64dbf..fa9382c5e 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -3,7 +3,7 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth import get_user_model -from django.core.mail import get_connection, EmailMessage +from django.core.mail import EmailMessage, get_connection from django.core.management.base import BaseCommand from django.db.models import Count from django.template.loader import render_to_string From fa015566b62ddf3ae745c1970df8b12ae02c233c Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Mon, 3 Sep 2018 09:00:46 +0200 Subject: [PATCH 517/980] Write tests with pytest.parametrize --- .../commands/notify_reviewers_unverified.py | 10 +-- .../tests/test_notify_reviewers_unverified.py | 72 +++++-------------- 2 files changed, 23 insertions(+), 59 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index fa9382c5e..c32b22bb6 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -1,3 +1,4 @@ +import re from datetime import date, timedelta from dateutil.relativedelta import relativedelta @@ -57,7 +58,6 @@ def add_arguments(self, parser): ) parser.add_argument( '--cc', - default=[], action='append', dest='cc', help='List of email addresses where to send a cc' @@ -104,6 +104,9 @@ def _notify_reviewers(self, start, end, reports, message, cc): from_email = settings.DEFAULT_FROM_EMAIL connection = get_connection() messages = [] + match = re.match('[^@]+@[^@]+\.[^@]+', ', '.join(cc)) + if not match: + cc = [''] for reviewer in reviewers: if reports.filter(task__project__reviewers=reviewer).exists(): @@ -124,13 +127,10 @@ def _notify_reviewers(self, start, end, reports, message, cc): body=body, from_email=from_email, to=[reviewer.email], + cc=cc, connection=connection ) - if cc: - message.cc = cc - messages.append(message) - if len(messages) > 0: connection.send_messages(messages) diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 603f03df9..90fef872a 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -1,5 +1,5 @@ from datetime import date - +import re import pytest from django.core.management import call_command @@ -9,7 +9,14 @@ @pytest.mark.freeze_time('2017-8-4') -def test_notify_reviewers(db, mailoutbox): +@pytest.mark.parametrize('cc,message', [ + ('', ''), + ('example@example.com', ''), + ('example@example.com', 'This is a test'), + ('', 'This is a test'), + ('test.ch', '') +]) +def test_notify_reviewers(db, mailoutbox, cc, message): """Test time range 2017-7-1 till 2017-7-31.""" # a reviewer which will be notified reviewer_work = UserFactory.create() @@ -29,64 +36,21 @@ def test_notify_reviewers(db, mailoutbox): call_command( 'notify_reviewers_unverified', - '--cc=example@example.com', - '--message=This is a test' - ) - - # checks - mail = mailoutbox[0] - assert len(mailoutbox) == 1 - assert mail.to == [reviewer_work.email] - url = ( - 'http://localhost:4200/analysis?fromDate=2017-07-01&' - 'toDate=2017-07-31&reviewer=%d&editable=1' - ) % reviewer_work.id - assert url in mail.body - assert 'This is a test' in mail.body - assert mail.cc == ['example@example.com'] - - call_command('notify_reviewers_unverified',) - - # checks - mail = mailoutbox[1] - assert len(mailoutbox) == 2 - assert mail.to == [reviewer_work.email] - url = ( - 'http://localhost:4200/analysis?fromDate=2017-07-01&' - 'toDate=2017-07-31&reviewer=%d&editable=1' - ) % reviewer_work.id - assert url in mail.body - assert mail.cc == [] - - call_command( - 'notify_reviewers_unverified', - '--message=This is a test' + '--cc={}'.format(cc), + '--message={}'.format(message) ) - # checks - mail = mailoutbox[2] - assert len(mailoutbox) == 3 - assert mail.to == [reviewer_work.email] - url = ( - 'http://localhost:4200/analysis?fromDate=2017-07-01&' - 'toDate=2017-07-31&reviewer=%d&editable=1' - ) % reviewer_work.id - assert url in mail.body - assert 'This is a test' in mail.body - assert mail.cc == [] - - call_command( - 'notify_reviewers_unverified', - '--cc=example@example.com' - ) + if not re.match(r'[^@]+@[^@]+\.[^@]+', ', '.join(cc)): + cc = '' - # checks - mail = mailoutbox[3] - assert len(mailoutbox) == 4 + # checks + assert len(mailoutbox) == 1 + mail = mailoutbox[0] assert mail.to == [reviewer_work.email] url = ( 'http://localhost:4200/analysis?fromDate=2017-07-01&' 'toDate=2017-07-31&reviewer=%d&editable=1' ) % reviewer_work.id assert url in mail.body - assert mail.cc == ['example@example.com'] + assert message in mail.body + assert mail.cc[0] == cc From 83ed9aa4e6968497d45117346d07ec1e56c505e9 Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Mon, 3 Sep 2018 09:11:06 +0200 Subject: [PATCH 518/980] Sort import to the correct position and use indexed parameters with format --- timed/reports/tests/test_notify_reviewers_unverified.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 90fef872a..3663b9b97 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -1,5 +1,6 @@ -from datetime import date import re +from datetime import date + import pytest from django.core.management import call_command @@ -36,8 +37,8 @@ def test_notify_reviewers(db, mailoutbox, cc, message): call_command( 'notify_reviewers_unverified', - '--cc={}'.format(cc), - '--message={}'.format(message) + '--cc={0}'.format(cc), + '--message={0}'.format(message) ) if not re.match(r'[^@]+@[^@]+\.[^@]+', ', '.join(cc)): From a8494a684b5a36d0220522f3d4548e76ce712b5e Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Mon, 3 Sep 2018 10:13:06 +0200 Subject: [PATCH 519/980] Add test case if the user leaves out the parameters --- .../commands/notify_reviewers_unverified.py | 4 -- .../tests/test_notify_reviewers_unverified.py | 37 +++++++++++++++---- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index c32b22bb6..230512b79 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -1,4 +1,3 @@ -import re from datetime import date, timedelta from dateutil.relativedelta import relativedelta @@ -104,9 +103,6 @@ def _notify_reviewers(self, start, end, reports, message, cc): from_email = settings.DEFAULT_FROM_EMAIL connection = get_connection() messages = [] - match = re.match('[^@]+@[^@]+\.[^@]+', ', '.join(cc)) - if not match: - cc = [''] for reviewer in reviewers: if reports.filter(task__project__reviewers=reviewer).exists(): diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 3663b9b97..a916da2a1 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -1,4 +1,3 @@ -import re from datetime import date import pytest @@ -14,10 +13,9 @@ ('', ''), ('example@example.com', ''), ('example@example.com', 'This is a test'), - ('', 'This is a test'), - ('test.ch', '') + ('', 'This is a test') ]) -def test_notify_reviewers(db, mailoutbox, cc, message): +def test_notify_reviewers_with_cc_and_message(db, mailoutbox, cc, message): """Test time range 2017-7-1 till 2017-7-31.""" # a reviewer which will be notified reviewer_work = UserFactory.create() @@ -41,10 +39,7 @@ def test_notify_reviewers(db, mailoutbox, cc, message): '--message={0}'.format(message) ) - if not re.match(r'[^@]+@[^@]+\.[^@]+', ', '.join(cc)): - cc = '' - - # checks + # checks assert len(mailoutbox) == 1 mail = mailoutbox[0] assert mail.to == [reviewer_work.email] @@ -55,3 +50,29 @@ def test_notify_reviewers(db, mailoutbox, cc, message): assert url in mail.body assert message in mail.body assert mail.cc[0] == cc + + +@pytest.mark.freeze_time('2017-8-4') +def test_notify_reviewers(db, mailoutbox): + """Test time range 2017-7-1 till 2017-7-31.""" + # a reviewer which will be notified + reviewer_work = UserFactory.create() + project_work = ProjectFactory.create() + project_work.reviewers.add(reviewer_work) + task_work = TaskFactory.create(project=project_work) + ReportFactory.create(date=date(2017, 7, 1), task=task_work, + verified_by=None) + + call_command( + 'notify_reviewers_unverified' + ) + + # checks + assert len(mailoutbox) == 1 + mail = mailoutbox[0] + assert mail.to == [reviewer_work.email] + url = ( + 'http://localhost:4200/analysis?fromDate=2017-07-01&' + 'toDate=2017-07-31&reviewer=%d&editable=1' + ) % reviewer_work.id + assert url in mail.body From ef69031732783b97bd3b9827edbda2eae88dd719 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 6 Sep 2018 06:45:44 -0700 Subject: [PATCH 520/980] Update pytest from 3.7.4 to 3.8.0 (#316) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 1416ce1e9..af91e40c5 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.7.4 +pytest==3.8.0 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From 4d3f4886446833be706f5c6b0f2ce8067c8e2247 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 17 Sep 2018 00:13:20 -0700 Subject: [PATCH 521/980] Update pytest-django from 3.4.2 to 3.4.3 (#317) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index af91e40c5..17a9dfec7 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -15,5 +15,5 @@ pytest==3.8.0 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 -pytest-django==3.4.2 +pytest-django==3.4.3 pytest-freezegun==0.2.0 From 2ab0e59a7c9be52b187aa57ef0339d9fb80f4b64 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 20 Sep 2018 07:52:51 -0700 Subject: [PATCH 522/980] Update djangorestframework-jsonapi from 2.5.0 to 2.6.0 (#318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8eeb0934e..584835ee4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-filter==2.0.0 django-multiselectfield==0.1.8 djangorestframework==3.8.2 djangorestframework-jwt==1.11.0 -djangorestframework-jsonapi==2.5.0 +djangorestframework-jsonapi==2.6.0 psycopg2-binary==2.7.5 pytz==2018.5 pyexcel-webio==0.1.4 From e547cb22051c85077915d097aeb68e23bd7d046d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sat, 22 Sep 2018 14:36:44 -0700 Subject: [PATCH 523/980] Update pytest from 3.8.0 to 3.8.1 (#319) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 17a9dfec7..d6c4fdba9 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.8.0 +pytest==3.8.1 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From a4448ae50655e4f419ca75c9b77ac146061a1577 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 8 Oct 2018 00:31:47 -0700 Subject: [PATCH 524/980] Update django from 1.11.15 to 1.11.16 (#320) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 584835ee4..9128b18fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.7.3 -django==1.11.15 # pyup: >=1.11,<1.12 +django==1.11.16 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 django-multiselectfield==0.1.8 From bc44f8653abd5e3fbcb2e6e5da08f2751a971181 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 8 Oct 2018 00:39:46 -0700 Subject: [PATCH 525/980] Update pytest from 3.8.1 to 3.8.2 (#321) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index d6c4fdba9..363bd33e3 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.8.1 +pytest==3.8.2 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From e2da8fa4ebf2ab830b39602eedf3f754157dd6d9 Mon Sep 17 00:00:00 2001 From: Jonas Lehmann Date: Mon, 8 Oct 2018 15:25:33 +0200 Subject: [PATCH 526/980] Properly render optional message in reviewer mail template (#322) --- .../management/commands/notify_reviewers_unverified.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index 230512b79..20e5697a3 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -95,7 +95,7 @@ def _get_unverified_reports(self, start, end): return queryset - def _notify_reviewers(self, start, end, reports, message, cc): + def _notify_reviewers(self, start, end, reports, optional_message, cc): """Notify reviewers on their unverified reports.""" User = get_user_model() reviewers = User.objects.all_reviewers().filter(email__isnull=False) @@ -111,7 +111,7 @@ def _notify_reviewers(self, start, end, reports, message, cc): # we need start and end date in system format 'start': str(start), 'end': str(end), - 'message': message, + 'message': optional_message, 'reviewer': reviewer, 'protocol': settings.HOST_PROTOCOL, 'domain': settings.HOST_DOMAIN, From 75877189957b5b78452177c5621a6f514daa63f9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 9 Oct 2018 23:39:24 -0700 Subject: [PATCH 527/980] Update django-money from 0.14.2 to 0.14.3 (#323) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9128b18fe..df778e60c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,6 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.5 rest_condition==1.0.3 -django-money==0.14.2 +django-money==0.14.3 python-redmine==2.1.1 uwsgi==2.0.17.1 From 423de36635b3bb8486f66a9c82ae95b03c5371b5 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 16 Oct 2018 23:41:17 -0700 Subject: [PATCH 528/980] Update pytest from 3.8.2 to 3.9.1 (#324) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 363bd33e3..74d9a4463 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.8.2 +pytest==3.9.1 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From 66afb2658edc60af776fb62f7de32ca2b6be8211 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 18 Oct 2018 05:19:43 -0700 Subject: [PATCH 529/980] Update djangorestframework from 3.8.2 to 3.9.0 (#325) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index df778e60c..161337e7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==1.11.16 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 django-multiselectfield==0.1.8 -djangorestframework==3.8.2 +djangorestframework==3.9.0 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.6.0 psycopg2-binary==2.7.5 From 34bf70ed79a26b1d0356fa32abd9807de3aa9177 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 23 Oct 2018 00:20:59 -0700 Subject: [PATCH 530/980] Update pytest from 3.9.1 to 3.9.2 (#326) --- dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 74d9a4463..7a00dcf29 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.9.1 +pytest==3.9.2 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From ca504a46a9e1816b5086a05f9b64c422a5a51f47 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 24 Oct 2018 02:49:43 -0700 Subject: [PATCH 531/980] Update pytz from 2018.5 to 2018.6 (#329) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 161337e7f..8436e1faf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.9.0 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.6.0 psycopg2-binary==2.7.5 -pytz==2018.5 +pytz==2018.6 pyexcel-webio==0.1.4 pyexcel-io==0.5.9.1 django-excel==0.0.10 From 196ceadd92b5de2548f37315892184de1654741e Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Wed, 24 Oct 2018 15:47:54 +0200 Subject: [PATCH 532/980] Update the development setup to only use docker-compose --- .travis.yml | 10 +-- Dockerfile | 8 ++- Makefile | 17 ++--- README.md | 69 ++++++++++---------- docker-compose.override.yml | 8 +++ docker-compose.yml | 8 +-- dev_requirements.txt => requirements-dev.txt | 0 7 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 docker-compose.override.yml rename dev_requirements.txt => requirements-dev.txt (100%) diff --git a/.travis.yml b/.travis.yml index 53d54e067..a5da66994 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,19 +7,21 @@ services: - postgresql cache: - - pip - - directories: + pip: true + directories: - .hypothesis install: - echo "ENV=travis" > .env - - make install-dev + - pip install --upgrade -r requirements.txt -r requirements-dev.txt before_script: - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres - psql -c "CREATE DATABASE timed;" -U postgres -script: make test +script: + - flake8 + - pytest --no-cov-on-fail --cov --create-db after_success: - bash <(curl -s https://codecov.io/bash) diff --git a/Dockerfile b/Dockerfile index 7a31802a4..701e45e86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ FROM python:3.6 +WORKDIR /app + +RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -P /usr/local/bin \ +&& chmod +x /usr/local/bin/wait-for-it.sh + RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ @@ -10,7 +15,6 @@ ENV STATIC_ROOT /var/www/static ENV UWSGI_INI /app/uwsgi.ini COPY . /app -WORKDIR /app RUN make install @@ -18,4 +22,4 @@ RUN mkdir -p /var/www/static \ && ENV=docker ./manage.py collectstatic --noinput EXPOSE 80 -CMD ["uwsgi"] +CMD /bin/sh -c "wait-for-it.sh $DJANGO_DATABASE_HOST:$DJANGO_DATABASE_PORT -- ./manage.py migrate && uwsgi" diff --git a/Makefile b/Makefile index 93fc27c26..77a51988b 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,18 @@ -.PHONY: help install install-dev setup-ldap create-ldap-user start docs test +.PHONY: help install install-dev start test .DEFAULT_GOAL := help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' install: ## Install production environment - @pip install --upgrade pip - @pip install --upgrade -r requirements.txt + @docker-compose exec backend pip install --upgrade -r requirements.txt install-dev: ## Install development environment - @pip install --upgrade pip - @pip install --upgrade -r requirements.txt -r dev_requirements.txt + @docker-compose exec backend pip install --upgrade -r requirements.txt -r requirements-dev.txt start: ## Start the development server - @docker-compose start db - @python manage.py runserver + @docker-compose up -d test: ## Test the project - ./manage.py migrate --noinput - ./manage.py makemigrations --check --dry-run --noinput - @flake8 - @pytest --no-cov-on-fail --cov --create-db + @docker-compose exec backend flake8 + @docker-compose exec backend pytest --no-cov-on-fail --cov --create-db diff --git a/README.md b/README.md index 19f31cdb0..f7e9076bc 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,15 @@ Timed timetracking software REST API built with Django ## Installation **Requirements** -* Min. python 3.5 -* docker -* docker-compose + +- docker +- docker-compose After installing and configuring those requirements, you should be able to run the following commands to complete the installation: + ```bash -# configure environment variables as listed in configuration chapter below -make install -docker-compose up -d db -./manage.py migrate -./manage.py createsuperuser +make start ``` You can now access the API at http://localhost:8000/api/v1 and the admin interface at http://localhost:8000/admin/ @@ -33,36 +30,42 @@ For end user interface have a look at our [Timed Frontend](https://github.com/ad Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) according to type. -| Parameter | Description | Default | -| ----------------------------------- | ---------------------------------------------------------- | -------------------------------- | -| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | -| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | -| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | -| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | -| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | -| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | -| `DJANGO_DATABASE_NAME` | Database name | timed | -| `DJANGO_DATABASE_USER` | Database username | timed | -| `DJANGO_DATABASE_HOST` | Database hostname | localhost | -| `DJANGO_DATABASE_PORT` | Database port | 5432 | -| `DJANGO_AUTH_LDAP_ENABLED` | Enable LDAP authentication | False | -| `DJANGO_AUTH_LDAP_SERVER_URI` | uri of LDAP server | not set | -| `DJANGO_AUTH_LDAP_BIND_DN` | distinguished name to use when binding to LDAP server | not set | -| `DJANGO_AUTH_LDAP_PASSWORD` | password to use with DJANGO_AUTH_LDAP_BIND_DN | not set | -| `DJANGO_AUTH_LDAP_USER_DN_TEMPLATE` | template to distinguish user’s username | not set | -| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | -| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | -| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | -| `DJANGO_ADMINS` | List of people who get error notifications | not set | -| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | - +| Parameter | Description | Default | +| ----------------------------------- | ----------------------------------------------------- | ------------------- | +| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | +| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | +| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | +| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | +| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | +| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | +| `DJANGO_DATABASE_NAME` | Database name | timed | +| `DJANGO_DATABASE_USER` | Database username | timed | +| `DJANGO_DATABASE_HOST` | Database hostname | localhost | +| `DJANGO_DATABASE_PORT` | Database port | 5432 | +| `DJANGO_AUTH_LDAP_ENABLED` | Enable LDAP authentication | False | +| `DJANGO_AUTH_LDAP_SERVER_URI` | uri of LDAP server | not set | +| `DJANGO_AUTH_LDAP_BIND_DN` | distinguished name to use when binding to LDAP server | not set | +| `DJANGO_AUTH_LDAP_PASSWORD` | password to use with DJANGO_AUTH_LDAP_BIND_DN | not set | +| `DJANGO_AUTH_LDAP_USER_DN_TEMPLATE` | template to distinguish user’s username | not set | +| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | +| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | +| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | +| `DJANGO_ADMINS` | List of people who get error notifications | not set | +| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | ## Development For development setup you can set environment variables `ENV=dev`. This way default values will be used. NOT TO BE USED IN PRODUCTION! -## Testing -Run tests by executing `make test` +### Testing + +Run tests by executing: + +```bash +make install-dev +make test +``` ## License + Code released under the [GNU Affero General Public License v3.0](LICENSE). diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 000000000..eaae2cb8b --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,8 @@ +version: "3" +services: + backend: + environment: + - PYTHONDONTWRITEBYTECODE=1 + volumes: + - ./:/app + command: /bin/sh -c "wait-for-it.sh db:3306 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" diff --git a/docker-compose.yml b/docker-compose.yml index 0d9270be9..54c55d05c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,16 @@ -version: '2' +version: "3" services: db: image: postgres:9.4 ports: - - '5432:5432' + - "5432:5432" environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed backend: build: . ports: - - '8000:80' - volumes: - - .:/app + - "8000:80" depends_on: - db environment: diff --git a/dev_requirements.txt b/requirements-dev.txt similarity index 100% rename from dev_requirements.txt rename to requirements-dev.txt From c8365e4246936b686381842a194673b96b988eb8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 25 Oct 2018 09:00:03 +0200 Subject: [PATCH 533/980] Add caching layer for update installation Includes additional ignores of files not be included --- .dockerignore | 16 ++++++++++++++-- Dockerfile | 5 +++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 6d4a87b10..e1fb0133d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,16 @@ +.cache +.coverage +.coverage.* +docker-compose.* +Dockerfile +.dockerignore +.env .git -docker-compose.yml -docker +*.pyc +__pycache__ +*.pyd +*.pyo +.pytest_cache +.Python +.python-version *.swp diff --git a/Dockerfile b/Dockerfile index 701e45e86..aaf44a14d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,10 @@ ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static ENV UWSGI_INI /app/uwsgi.ini -COPY . /app +COPY requirements.txt /app +RUN pip install --upgrade -r requirements.txt -RUN make install +COPY . /app RUN mkdir -p /var/www/static \ && ENV=docker ./manage.py collectstatic --noinput From 6254e58b315d40c03426c994ff63748f64d588ae Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 25 Oct 2018 11:21:33 -0700 Subject: [PATCH 534/980] Update python-dateutil from 2.7.3 to 2.7.4 (#333) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8436e1faf..e501d018e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.7.3 +python-dateutil==2.7.4 django==1.11.16 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 From 9448e8e584aa50a848063e4606448f3fb4b073ca Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 26 Oct 2018 15:31:37 +0200 Subject: [PATCH 535/980] Enforce deprecation warning to be an error When updating dependencies this will trigger when new deprecation warning are introduced before it gets merged. In case of a warning which can not be fixed it can still be added to pytest.ini and comment why it is excluded. For this to work fixed all current deprecation warnings. --- pytest.ini | 19 ++++++++++ .../migrations/0012_auto_20181026_1528.py | 26 +++++++++++++ timed/employment/models.py | 10 ++++- .../employment/tests/test_absence_balance.py | 2 +- timed/employment/tests/test_absence_credit.py | 2 +- timed/employment/tests/test_absence_type.py | 2 +- timed/employment/tests/test_employment.py | 2 +- timed/employment/tests/test_location.py | 2 +- .../employment/tests/test_overtime_credit.py | 2 +- timed/employment/tests/test_public_holiday.py | 2 +- timed/employment/tests/test_user.py | 2 +- .../employment/tests/test_worktime_balance.py | 2 +- timed/projects/models.py | 2 + timed/projects/tests/test_billing_type.py | 2 +- timed/projects/tests/test_cost_center.py | 2 +- timed/projects/tests/test_customer.py | 2 +- timed/projects/tests/test_project.py | 2 +- timed/projects/tests/test_task.py | 2 +- timed/redmine/tests/test_redmine_report.py | 6 +-- .../reports/tests/test_customer_statistic.py | 2 +- timed/reports/tests/test_month_statistic.py | 2 +- timed/reports/tests/test_project_statistic.py | 2 +- timed/reports/tests/test_task_statistic.py | 2 +- timed/reports/tests/test_user_statistic.py | 2 +- timed/reports/tests/test_work_report.py | 2 +- timed/reports/tests/test_year_statistic.py | 2 +- timed/reports/views.py | 4 +- timed/settings.py | 5 +-- timed/subscription/models.py | 7 +++- timed/subscription/tests/test_order.py | 2 +- timed/subscription/tests/test_package.py | 2 +- .../tests/test_subscription_project.py | 2 +- timed/subscription/views.py | 3 +- timed/tests/client.py | 2 +- .../migrations/0011_auto_20181026_1528.py | 37 +++++++++++++++++++ timed/tracking/models.py | 10 ++++- timed/tracking/tests/test_absence.py | 2 +- timed/tracking/tests/test_activity.py | 2 +- timed/tracking/tests/test_attendance.py | 2 +- timed/tracking/tests/test_report.py | 2 +- 40 files changed, 144 insertions(+), 43 deletions(-) create mode 100644 timed/employment/migrations/0012_auto_20181026_1528.py create mode 100644 timed/tracking/migrations/0011_auto_20181026_1528.py diff --git a/pytest.ini b/pytest.ini index 73d9a5b1e..fb83c3023 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,3 +5,22 @@ env = DJANGO_AUTH_LDAP_ENABLED=True DJANGO_AUTH_LDAP_SERVER_URI=ldap://127.0.0.1 DJANGO_AUTH_LDAP_USER_DN_TEMPLATE=uid=%(user)s,ou=people,o=test +filterwarnings = + error::DeprecationWarning + error::PendingDeprecationWarning + + # ignore rest_framework_jwt deprecation warning which is not fixiable + # but simply a information + ignore:The following fields will be removed in the future:DeprecationWarning + + # TODO: adjust frontend for this change to work + ignore:PageNumberPagination is deprecated. Use JsonApiPageNumberPagination instead. + + # This warning is caused by rest_conditions + # TODO: replace rest_conditions with composable permission classes + # https://www.django-rest-framework.org/community/3.9-announcement/#composable-permission-classes + ignore:Using user\.is_authenticated\(\) and user\.is_anonymous\(\) + + # ignore pytest-freezegun warning till following PR is released + # https://github.com/ktosiek/pytest-freezegun/pull/4 + ignore:MarkInfo objects are deprecated as they contain merged marks which are hard to deal with correctly. diff --git a/timed/employment/migrations/0012_auto_20181026_1528.py b/timed/employment/migrations/0012_auto_20181026_1528.py new file mode 100644 index 000000000..373085573 --- /dev/null +++ b/timed/employment/migrations/0012_auto_20181026_1528.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-10-26 13:28 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('employment', '0011_auto_20171101_1227'), + ] + + operations = [ + migrations.AlterField( + model_name='absencecredit', + name='absence_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='employment.AbsenceType'), + ), + migrations.AlterField( + model_name='employment', + name='location', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employments', to='employment.Location'), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 037de1c0e..1b63343a3 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -48,6 +48,7 @@ class PublicHoliday(models.Model): name = models.CharField(max_length=50) date = models.DateField() location = models.ForeignKey(Location, + on_delete=models.CASCADE, related_name='public_holidays') def __str__(self): @@ -132,9 +133,10 @@ class AbsenceCredit(models.Model): """ user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, related_name='absence_credits') comment = models.CharField(max_length=255, blank=True) - absence_type = models.ForeignKey(AbsenceType) + absence_type = models.ForeignKey(AbsenceType, on_delete=models.PROTECT) date = models.DateField() days = models.IntegerField(default=0) transfer = models.BooleanField(default=False) @@ -151,6 +153,7 @@ class OvertimeCredit(models.Model): """ user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, related_name='overtime_credits') comment = models.CharField(max_length=255, blank=True) date = models.DateField() @@ -209,8 +212,11 @@ class Employment(models.Model): """ user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='employments') + location = models.ForeignKey(Location, + on_delete=models.PROTECT, related_name='employments') - location = models.ForeignKey(Location, related_name='employments') percentage = models.IntegerField(validators=[ MinValueValidator(0), MaxValueValidator(100)]) diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py index f724e523c..5e7b189d9 100644 --- a/timed/employment/tests/test_absence_balance.py +++ b/timed/employment/tests/test_absence_balance.py @@ -1,6 +1,6 @@ from datetime import date, timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import (AbsenceCreditFactory, diff --git a/timed/employment/tests/test_absence_credit.py b/timed/employment/tests/test_absence_credit.py index 461ffc757..b7fb3a212 100644 --- a/timed/employment/tests/test_absence_credit.py +++ b/timed/employment/tests/test_absence_credit.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import (AbsenceCreditFactory, diff --git a/timed/employment/tests/test_absence_type.py b/timed/employment/tests/test_absence_type.py index c886106b4..a571f09f6 100644 --- a/timed/employment/tests/test_absence_type.py +++ b/timed/employment/tests/test_absence_type.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import AbsenceTypeFactory diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index c9c34e622..6961e4c96 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -3,7 +3,7 @@ from datetime import date, timedelta import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment import factories diff --git a/timed/employment/tests/test_location.py b/timed/employment/tests/test_location.py index 0fdd33d41..5aa46e762 100644 --- a/timed/employment/tests/test_location.py +++ b/timed/employment/tests/test_location.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import LocationFactory diff --git a/timed/employment/tests/test_overtime_credit.py b/timed/employment/tests/test_overtime_credit.py index 8c197c726..16c5b62ae 100644 --- a/timed/employment/tests/test_overtime_credit.py +++ b/timed/employment/tests/test_overtime_credit.py @@ -1,6 +1,6 @@ """Tests for the overtime credits endpoint.""" -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import OvertimeCreditFactory, UserFactory diff --git a/timed/employment/tests/test_public_holiday.py b/timed/employment/tests/test_public_holiday.py index 88fafcfa9..6ae063d20 100644 --- a/timed/employment/tests/test_public_holiday.py +++ b/timed/employment/tests/test_public_holiday.py @@ -1,6 +1,6 @@ from datetime import date -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import PublicHolidayFactory diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index b37842e77..dfd0c322d 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -2,7 +2,7 @@ import pytest from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, diff --git a/timed/employment/tests/test_worktime_balance.py b/timed/employment/tests/test_worktime_balance.py index 8122ba05f..9cef6d8da 100644 --- a/timed/employment/tests/test_worktime_balance.py +++ b/timed/employment/tests/test_worktime_balance.py @@ -1,7 +1,7 @@ from datetime import date, timedelta import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse from django.utils.duration import duration_string from rest_framework import status diff --git a/timed/projects/models.py b/timed/projects/models.py index 7d0f5a89c..914a64bb6 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -73,6 +73,7 @@ class Project(models.Model): archived = models.BooleanField(default=False) estimated_time = models.DurationField(blank=True, null=True) customer = models.ForeignKey('projects.Customer', + on_delete=models.CASCADE, related_name='projects') billing_type = models.ForeignKey(BillingType, on_delete=models.SET_NULL, @@ -111,6 +112,7 @@ class Task(models.Model): estimated_time = models.DurationField(blank=True, null=True) archived = models.BooleanField(default=False) project = models.ForeignKey('projects.Project', + on_delete=models.CASCADE, related_name='tasks') cost_center = models.ForeignKey(CostCenter, on_delete=models.SET_NULL, blank=True, null=True, diff --git a/timed/projects/tests/test_billing_type.py b/timed/projects/tests/test_billing_type.py index e3d597561..b7d1ed5c0 100644 --- a/timed/projects/tests/test_billing_type.py +++ b/timed/projects/tests/test_billing_type.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework.status import HTTP_200_OK from timed.projects.factories import BillingTypeFactory diff --git a/timed/projects/tests/test_cost_center.py b/timed/projects/tests/test_cost_center.py index 66e034ca8..0dbf07f75 100644 --- a/timed/projects/tests/test_cost_center.py +++ b/timed/projects/tests/test_cost_center.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework.status import HTTP_200_OK from timed.projects.factories import CostCenterFactory diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 38190e30d..5d0de9d63 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -1,6 +1,6 @@ """Tests for the customers endpoint.""" -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.projects.factories import CustomerFactory diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 26f8fd78d..079d1da7a 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,7 +1,7 @@ """Tests for the projects endpoint.""" from datetime import timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import UserFactory diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 5c595ff64..99af34788 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -1,7 +1,7 @@ """Tests for the tasks endpoint.""" from datetime import date, timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.projects.factories import TaskFactory diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index b1f18dda8..0d86a2f91 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -28,7 +28,7 @@ def test_redmine_report(db, freezer, mocker): ReportFactory.create() freezer.move_to('2017-07-31') - call_command('redmine_report', options={'--last-days': '7'}) + call_command('redmine_report', last_days=7) redmine_instance.issue.get.assert_called_once_with(1000) assert issue.custom_fields == [{ @@ -59,7 +59,7 @@ def test_redmine_report_no_estimated_time(db, freezer, mocker): RedmineProject.objects.create(project=report.task.project, issue_id=1000) freezer.move_to('2017-07-31') - call_command('redmine_report', options={'--last-days': '7'}) + call_command('redmine_report', last_days=7) redmine_instance.issue.get.assert_called_once_with(1000) issue.save.assert_called_once_with() @@ -77,7 +77,7 @@ def test_redmine_report_invalid_issue(db, freezer, mocker, capsys): RedmineProject.objects.create(project=report.task.project, issue_id=1000) freezer.move_to('2017-07-31') - call_command('redmine_report', options={'--last-days': '7'}) + call_command('redmine_report', last_days=7) _, err = capsys.readouterr() assert 'issue 1000 assigned' in err diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index f0bfe3969..eb4d0bf11 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -1,6 +1,6 @@ from datetime import timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from timed.tracking.factories import ReportFactory diff --git a/timed/reports/tests/test_month_statistic.py b/timed/reports/tests/test_month_statistic.py index a167b1796..7ceaac40f 100644 --- a/timed/reports/tests/test_month_statistic.py +++ b/timed/reports/tests/test_month_statistic.py @@ -1,6 +1,6 @@ from datetime import date, timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from timed.tracking.factories import ReportFactory diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index e312eec71..5e82d8929 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -1,6 +1,6 @@ from datetime import timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from timed.tracking.factories import ReportFactory diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index 127be35e1..45bbd9980 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -1,6 +1,6 @@ from datetime import timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from timed.projects.factories import TaskFactory from timed.tracking.factories import ReportFactory diff --git a/timed/reports/tests/test_user_statistic.py b/timed/reports/tests/test_user_statistic.py index 2d5c8f08c..644844dd1 100644 --- a/timed/reports/tests/test_user_statistic.py +++ b/timed/reports/tests/test_user_statistic.py @@ -1,6 +1,6 @@ from datetime import timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from timed.tracking.factories import ReportFactory diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 0f464c4bb..90dc77ed7 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -4,7 +4,7 @@ import ezodf import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST from timed.projects.factories import (CustomerFactory, ProjectFactory, diff --git a/timed/reports/tests/test_year_statistic.py b/timed/reports/tests/test_year_statistic.py index 4b9595a01..e9742ab91 100644 --- a/timed/reports/tests/test_year_statistic.py +++ b/timed/reports/tests/test_year_statistic.py @@ -1,6 +1,6 @@ from datetime import date, timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from timed.tracking.factories import ReportFactory diff --git a/timed/reports/views.py b/timed/reports/views.py index 130efac90..e833856de 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -174,8 +174,8 @@ def _clean_filename(self, name): To accomplish this it will remove all special chars and replace spaces with underscores """ - escaped = re.sub('[^\w\s-]', '', name) - return re.sub('\s+', '_', escaped) + escaped = re.sub(r'[^\w\s-]', '', name) + return re.sub(r'\s+', '_', escaped) def _generate_workreport_name(self, from_date, today, project): """ diff --git a/timed/settings.py b/timed/settings.py index d2158dc09..b4f87423e 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -77,7 +77,6 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -177,7 +176,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ), } -JSON_API_FORMAT_KEYS = 'dasherize' +JSON_API_FORMAT_FIELD_NAMES = 'dasherize' JSON_API_FORMAT_TYPES = 'dasherize' JSON_API_PLURALIZE_TYPES = True @@ -260,7 +259,7 @@ def parse_admins(admins): """ result = [] for admin in admins: - match = re.search('(.+) \<(.+@.+)\>', admin) + match = re.search(r'(.+) \<(.+@.+)\>', admin) if not match: raise environ.ImproperlyConfigured( 'In DJANGO_ADMINS admin "{0}" is not in correct ' diff --git a/timed/subscription/models.py b/timed/subscription/models.py index 2905ffab7..6986a59ea 100644 --- a/timed/subscription/models.py +++ b/timed/subscription/models.py @@ -8,7 +8,9 @@ class Package(models.Model): """Representing a subscription package.""" - billing_type = models.ForeignKey('projects.BillingType', null=True, + billing_type = models.ForeignKey('projects.BillingType', + on_delete=models.CASCADE, + null=True, related_name='packages') """ This field has been added later so there might be old entries with null @@ -44,6 +46,7 @@ class CustomerPassword(models.Model): once customer center will go live. """ - customer = models.OneToOneField('projects.Customer') + customer = models.OneToOneField('projects.Customer', + on_delete=models.CASCADE) password = models.CharField(_('password'), max_length=128, null=True, blank=True) diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index a86d27327..d2b533080 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.subscription import factories diff --git a/timed/subscription/tests/test_package.py b/timed/subscription/tests/test_package.py index fef30778b..fc633cc8e 100644 --- a/timed/subscription/tests/test_package.py +++ b/timed/subscription/tests/test_package.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework.status import HTTP_200_OK from timed.projects.factories import (BillingTypeFactory, CustomerFactory, diff --git a/timed/subscription/tests/test_subscription_project.py b/timed/subscription/tests/test_subscription_project.py index eb15ba8dc..fe737987e 100644 --- a/timed/subscription/tests/test_subscription_project.py +++ b/timed/subscription/tests/test_subscription_project.py @@ -1,6 +1,6 @@ from datetime import timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework.status import HTTP_200_OK from timed.projects.factories import (BillingTypeFactory, CustomerFactory, diff --git a/timed/subscription/views.py b/timed/subscription/views.py index b54772314..d3a2c9bf0 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -47,7 +47,8 @@ class OrderViewSet(mixins.CreateModelMixin, serializer_class = serializers.OrderSerializer filterset_class = filters.OrderFilter - @decorators.detail_route( + @decorators.action( + detail=True, methods=['post'], permission_classes=[ permissions.IsAuthenticated, diff --git a/timed/tests/client.py b/timed/tests/client.py index 94180965f..5a47eb005 100644 --- a/timed/tests/client.py +++ b/timed/tests/client.py @@ -2,7 +2,7 @@ import json -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import exceptions, status from rest_framework.test import APIClient from rest_framework_jwt.settings import api_settings diff --git a/timed/tracking/migrations/0011_auto_20181026_1528.py b/timed/tracking/migrations/0011_auto_20181026_1528.py new file mode 100644 index 000000000..3c129773a --- /dev/null +++ b/timed/tracking/migrations/0011_auto_20181026_1528.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-10-26 13:28 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0010_auto_20180904_0818'), + ] + + operations = [ + migrations.AlterField( + model_name='absence', + name='type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='absences', to='employment.AbsenceType'), + ), + migrations.AlterField( + model_name='activity', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='projects.Task'), + ), + migrations.AlterField( + model_name='report', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reports', to='projects.Task'), + ), + migrations.AlterField( + model_name='report', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reports', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index a0837820f..c33953c00 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -23,8 +23,10 @@ class Activity(models.Model): task = models.ForeignKey('projects.Task', null=True, blank=True, + on_delete=models.SET_NULL, related_name='activities') user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, related_name='activities') def __str__(self): @@ -54,6 +56,7 @@ class Attendance(models.Model): from_time = models.TimeField() to_time = models.TimeField() user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, related_name='attendances') def __str__(self): @@ -83,8 +86,11 @@ class Report(models.Model): duration = models.DurationField() review = models.BooleanField(default=False) not_billable = models.BooleanField(default=False) - task = models.ForeignKey('projects.Task', related_name='reports') + task = models.ForeignKey('projects.Task', + on_delete=models.PROTECT, + related_name='reports') user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, related_name='reports') verified_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -144,8 +150,10 @@ class Absence(models.Model): comment = models.TextField(blank=True) date = models.DateField() type = models.ForeignKey('employment.AbsenceType', + on_delete=models.PROTECT, related_name='absences') user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, related_name='absences') objects = AbsenceManager() diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 817935284..af4cd8547 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -1,6 +1,6 @@ import datetime -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index a6683df6a..950a1dea0 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -1,6 +1,6 @@ from datetime import date, time, timedelta -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.projects.factories import TaskFactory diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index 16001e7ca..3a63a6fe5 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from timed.tracking.factories import AttendanceFactory diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 2d1102d3a..d401042a5 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -4,7 +4,7 @@ import pyexcel import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse from django.utils.duration import duration_string from rest_framework import status From 82b9ebe8ed6f87a4b0650f7f19a1f57d762780fd Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 29 Oct 2018 11:23:14 +0100 Subject: [PATCH 536/980] Update pytz from 2018.6 to 2018.7 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e501d018e..d9f9243fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.9.0 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.6.0 psycopg2-binary==2.7.5 -pytz==2018.6 +pytz==2018.7 pyexcel-webio==0.1.4 pyexcel-io==0.5.9.1 django-excel==0.0.10 From 88aba1cbf33b1b6c4f57aa249ae8526126f9fd9f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 31 Oct 2018 08:40:12 -0700 Subject: [PATCH 537/980] Update python-dateutil from 2.7.4 to 2.7.5 (#336) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d9f9243fb..3870ab6a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.7.4 +python-dateutil==2.7.5 django==1.11.16 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 From ebee6f6dc521adab9eb865b80dabd19e5f76757c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 31 Oct 2018 08:55:36 -0700 Subject: [PATCH 538/980] Update pytest from 3.9.2 to 3.9.3 (#335) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7a00dcf29..561c8758c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.9.2 +pytest==3.9.3 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From 8f910463bc126acbe7bad7ed583c9cca97f9d636 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 4 Nov 2018 23:42:20 -0800 Subject: [PATCH 539/980] Update pytest from 3.9.3 to 3.10.0 (#338) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 561c8758c..23226ebd3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.9.3 +pytest==3.10.0 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From 34fb9e8bbb7790d133e92e1e4a45934561c589b9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 11 Nov 2018 23:10:41 -0800 Subject: [PATCH 540/980] Update pytest from 3.10.0 to 3.10.1 (#341) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 23226ebd3..87fd63d8f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.10.0 +pytest==3.10.1 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From 0c3babbd0fc9a76c532489ec081348a355a3320f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 11 Nov 2018 23:31:05 -0800 Subject: [PATCH 541/980] Update psycopg2-binary from 2.7.5 to 2.7.6.1 (#340) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3870ab6a9..cfd86cb0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.9.0 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.6.0 -psycopg2-binary==2.7.5 +psycopg2-binary==2.7.6.1 pytz==2018.7 pyexcel-webio==0.1.4 pyexcel-io==0.5.9.1 From 703cddffadd994dfaa0c829433da2b51382eeab5 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 12 Nov 2018 23:43:07 -0800 Subject: [PATCH 542/980] Update coverage from 4.5.1 to 4.5.2 (#342) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 87fd63d8f..ca21ad35d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -coverage==4.5.1 +coverage==4.5.2 factory-boy==2.11.1 flake8==3.5.0 flake8-blind-except==0.1.1 From fdd989d2b8f778a37b5d0759ae51bb2420ccabc8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 13 Nov 2018 01:55:41 -0800 Subject: [PATCH 543/980] Update pytest-django from 3.4.3 to 3.4.4 (#343) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index ca21ad35d..991d18eea 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -15,5 +15,5 @@ pytest==3.10.1 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 -pytest-django==3.4.3 +pytest-django==3.4.4 pytest-freezegun==0.2.0 From dad22b1feb77b9bb88a8c19677654f9e316c66c0 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 14 Nov 2018 23:42:52 -0800 Subject: [PATCH 544/980] Update pytest from 3.10.1 to 4.0.0 (#344) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 991d18eea..0f0e85c75 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==3.10.1 +pytest==4.0.0 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From a9aedeed1095a80969bf50e66190124c9c8b2580 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 15 Nov 2018 23:43:45 -0800 Subject: [PATCH 545/980] Update pytest-freezegun from 0.2.0 to 0.3.0 (#345) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0f0e85c75..0b12e3d8f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -16,4 +16,4 @@ pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 pytest-django==3.4.4 -pytest-freezegun==0.2.0 +pytest-freezegun==0.3.0 From 6db0f2a152eec79462e9248c684542a784e6e24c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 19 Nov 2018 08:42:31 +0100 Subject: [PATCH 546/980] Remove obsolete ignore deprecation warning (#346) This was fixed in pytest-freezegun 0.3.0 --- pytest.ini | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pytest.ini b/pytest.ini index fb83c3023..164b97ea9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -20,7 +20,3 @@ filterwarnings = # TODO: replace rest_conditions with composable permission classes # https://www.django-rest-framework.org/community/3.9-announcement/#composable-permission-classes ignore:Using user\.is_authenticated\(\) and user\.is_anonymous\(\) - - # ignore pytest-freezegun warning till following PR is released - # https://github.com/ktosiek/pytest-freezegun/pull/4 - ignore:MarkInfo objects are deprecated as they contain merged marks which are hard to deal with correctly. From bd286f49f80b1a5b5b2a923ba45b9210a8d81a2b Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 25 Nov 2018 23:42:48 -0800 Subject: [PATCH 547/980] Update pytest from 4.0.0 to 4.0.1 (#348) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0b12e3d8f..7808a0200 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==4.0.0 +pytest==4.0.1 pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 From fdcf1320c3206ebcf145fc2c3f647abf48e2c716 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 26 Nov 2018 03:41:49 -0800 Subject: [PATCH 548/980] Update pytest-freezegun from 0.3.0 to 0.3.0.post1 (#347) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7808a0200..99b8e172e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -16,4 +16,4 @@ pytest-cov==2.6.0 pytest-env==0.6.2 pytest-mock==1.10.0 pytest-django==3.4.4 -pytest-freezegun==0.3.0 +pytest-freezegun==0.3.0.post1 From a036192d5c8f360deef465c14c297f270e498f31 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 26 Nov 2018 16:26:36 +0100 Subject: [PATCH 549/980] Improve docker setup (#350) * Add database volume * Build docker container when starting --- Makefile | 2 +- docker-compose.yml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 77a51988b..ac444f045 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ install-dev: ## Install development environment @docker-compose exec backend pip install --upgrade -r requirements.txt -r requirements-dev.txt start: ## Start the development server - @docker-compose up -d + @docker-compose up -d --build test: ## Test the project @docker-compose exec backend flake8 diff --git a/docker-compose.yml b/docker-compose.yml index 54c55d05c..63b00d1e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,15 @@ version: "3" + +volumes: + dbdata: + services: db: image: postgres:9.4 ports: - "5432:5432" + volumes: + - dbdata:/var/lib/postgresq environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed From ead6fbb71abae451184e8454a526aefd3eea9960 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 26 Nov 2018 16:33:44 +0100 Subject: [PATCH 550/980] Allow bulk verification and changes together (#349) --- timed/tracking/tests/test_report.py | 4 +++- timed/tracking/views.py | 31 ++++++++++++----------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index d401042a5..5c2099368 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -433,7 +433,8 @@ def test_report_update_bulk_verify_reviewer(auth_client): 'type': 'report-bulks', 'id': None, 'attributes': { - 'verified': True + 'verified': True, + 'comment': 'some comment' } } } @@ -445,6 +446,7 @@ def test_report_update_bulk_verify_reviewer(auth_client): report.refresh_from_db() assert report.verified_by == user + assert report.comment == 'some comment' def test_report_update_bulk_reset_verify(superadmin_client): diff --git a/timed/tracking/views.py b/timed/tracking/views.py index e319e7db3..93732b95f 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,7 +1,6 @@ """Viewsets for the tracking app.""" import django_excel -from django.db import transaction from django.db.models import Q from django.http import HttpResponseBadRequest from django.utils.translation import ugettext_lazy as _ @@ -183,25 +182,21 @@ def bulk(self, request): _('Editable filter needs to be set for bulk update') ) - with transaction.atomic(): - verified = serializer.validated_data.get('verified') - if verified is not None: - # only reviewer or superuser may verify reports - # this is enforced when reviewer filter is set to current user - reviewer_id = request.query_params.get('reviewer') - if not user.is_superuser and str(reviewer_id) != str(user.id): - raise exceptions.ParseError( - _('Reviewer filter needs to be set to verifying user') - ) - - verified_by = verified and user or None - queryset.filter(verified_by__isnull=verified).update( - verified_by=verified_by + verified = serializer.validated_data.get('verified') + if verified is not None: + # only reviewer or superuser may verify reports + # this is enforced when reviewer filter is set to current user + reviewer_id = request.query_params.get('reviewer') + if not user.is_superuser and str(reviewer_id) != str(user.id): + raise exceptions.ParseError( + _('Reviewer filter needs to be set to verifying user') ) - # update all other fields - if fields: - queryset.update(**fields) + verified_by = verified and user or None + fields['verified_by'] = verified_by + + if fields: + queryset.update(**fields) return Response(status=status.HTTP_204_NO_CONTENT) From 9578c4e209d74398a7a0cd10aef6a6381c74cd3a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 27 Nov 2018 00:14:43 -0800 Subject: [PATCH 551/980] Update pyexcel-io from 0.5.9.1 to 0.5.10 (#351) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfd86cb0b..1ea8944ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.6.0 psycopg2-binary==2.7.6.1 pytz==2018.7 pyexcel-webio==0.1.4 -pyexcel-io==0.5.9.1 +pyexcel-io==0.5.10 django-excel==0.0.10 pyexcel-ods3==0.5.2 pyexcel-xlsx==0.5.6 From 9b1040ef4164b57659e0f2d69abb9a8b1181eac6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 27 Nov 2018 00:22:05 -0800 Subject: [PATCH 552/980] Update pyexcel-ods3 from 0.5.2 to 0.5.3 (#352) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1ea8944ff..3b3b92a9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pytz==2018.7 pyexcel-webio==0.1.4 pyexcel-io==0.5.10 django-excel==0.0.10 -pyexcel-ods3==0.5.2 +pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.5 From ca23a345859ea77319944dbb99cb2ec5d73badf7 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 2 Dec 2018 23:46:23 -0800 Subject: [PATCH 553/980] Update flake8-isort from 2.5 to 2.6.0 (#353) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 99b8e172e..74c42c7b0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,7 +5,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 -flake8-isort==2.5 +flake8-isort==2.6.0 flake8-quotes==1.0.0 flake8-string-format==0.2.3 ipdb==0.11 From 5137df3689971351f73791c1e8de28229fc5e9bb Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 3 Dec 2018 23:39:55 -0800 Subject: [PATCH 554/980] Update pyexcel-io from 0.5.10 to 0.5.11 (#355) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3b3b92a9f..e11e61dcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.6.0 psycopg2-binary==2.7.6.1 pytz==2018.7 pyexcel-webio==0.1.4 -pyexcel-io==0.5.10 +pyexcel-io==0.5.11 django-excel==0.0.10 pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.6 From d03c505db1463a8e54bb1aea4bce8091f8e03bf5 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 3 Dec 2018 23:45:57 -0800 Subject: [PATCH 555/980] Update django from 1.11.16 to 1.11.17 (#354) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e11e61dcc..c3e797ca7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.7.5 -django==1.11.16 # pyup: >=1.11,<1.12 +django==1.11.17 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 django-multiselectfield==0.1.8 From 892906dd5fd90d16d5d8848180bc236149cd6308 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 7 Dec 2018 10:24:10 +0100 Subject: [PATCH 556/980] Make random data more readable (#356) uuid4 is used in factories to avoid errors in tests with unique constraints. Drawback of this is that the random data is really hard to read. To fix the unique constraint issue a fix seed is introduced and factory can therefore create more readable data. --- Makefile | 2 +- pytest.ini | 2 +- requirements-dev.txt | 6 ++++-- timed/employment/factories.py | 5 ++--- timed/projects/factories.py | 9 +++++---- timed/reports/tests/test_notify_supervisors_shorttime.py | 4 +++- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index ac444f045..8290e868d 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ install: ## Install production environment @docker-compose exec backend pip install --upgrade -r requirements.txt install-dev: ## Install development environment - @docker-compose exec backend pip install --upgrade -r requirements.txt -r requirements-dev.txt + @docker-compose exec backend pip install --upgrade -r requirements-dev.txt start: ## Start the development server @docker-compose up -d --build diff --git a/pytest.ini b/pytest.ini index 164b97ea9..63d5567e0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] DJANGO_SETTINGS_MODULE=timed.settings -addopts = --reuse-db +addopts = --reuse-db --randomly-seed=1521188767 --randomly-dont-reorganize env = DJANGO_AUTH_LDAP_ENABLED=True DJANGO_AUTH_LDAP_SERVER_URI=ldap://127.0.0.1 diff --git a/requirements-dev.txt b/requirements-dev.txt index 74c42c7b0..77bfb13f0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ +-r requirements.txt coverage==4.5.2 factory-boy==2.11.1 flake8==3.5.0 @@ -13,7 +14,8 @@ isort==4.3.4 mockldap==0.3.0 pytest==4.0.1 pytest-cov==2.6.0 -pytest-env==0.6.2 -pytest-mock==1.10.0 pytest-django==3.4.4 +pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 +pytest-mock==1.10.0 +pytest-randomly==1.2.3 diff --git a/timed/employment/factories.py b/timed/employment/factories.py index 766f2e5f2..056d5917f 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -17,7 +17,7 @@ class UserFactory(DjangoModelFactory): last_name = Faker('last_name') email = Faker('email') password = Faker('password', length=12) - username = Faker('uuid4') + username = Faker('user_name') class Meta: """Meta informations for the user factory.""" @@ -28,8 +28,7 @@ class Meta: class LocationFactory(DjangoModelFactory): """Location factory.""" - name = Faker('uuid4') - # cannot use city provider as name needs to be unique + name = Faker('city') class Meta: """Meta informations for the location factory.""" diff --git a/timed/projects/factories.py b/timed/projects/factories.py index 55cff6034..a88a4c9df 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -9,7 +9,7 @@ class CustomerFactory(DjangoModelFactory): """Customer factory.""" - name = Faker('uuid4') + name = Faker('company') email = Faker('company_email') website = Faker('url') comment = Faker('sentence') @@ -22,15 +22,16 @@ class Meta: class BillingTypeFactory(DjangoModelFactory): - name = Faker('uuid4') + name = Faker('currency_name') + reference = None class Meta: model = models.BillingType class CostCenterFactory(DjangoModelFactory): - name = Faker('uuid4') - reference = Faker('uuid4') + name = Faker('job') + reference = None class Meta: model = models.CostCenter diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py index 68efc90b8..03fe3a81b 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -5,6 +5,7 @@ from django.core.management import call_command from timed.employment.factories import EmploymentFactory, UserFactory +from timed.projects.factories import TaskFactory from timed.tracking.factories import ReportFactory @@ -23,8 +24,9 @@ def test_notify_supervisors(db, mailoutbox): workdays = rrule(DAILY, dtstart=start, until=date.today(), # range is excluding last byweekday=range(MO.weekday, FR.weekday + 1)) + task = TaskFactory.create() for dt in workdays: - ReportFactory.create(user=supervisee, date=dt, + ReportFactory.create(user=supervisee, date=dt, task=task, duration=timedelta(hours=7)) call_command('notify_supervisors_shorttime') From 5bba26571844c76089c5f5773a85d7715509fbd8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 14 Dec 2018 03:21:32 -0800 Subject: [PATCH 557/980] Update pytest from 4.0.1 to 4.0.2 (#357) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 77bfb13f0..e3ea9b4a3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==4.0.1 +pytest==4.0.2 pytest-cov==2.6.0 pytest-django==3.4.4 pytest-env==0.6.2 From 68eae97b2e6d63ddc5654b7df3d5675d365b7785 Mon Sep 17 00:00:00 2001 From: Yelin Zhang <30687616+Yelinz@users.noreply.github.com> Date: Mon, 17 Dec 2018 17:04:23 +0100 Subject: [PATCH 558/980] Add task row and task totals to workreport (#358) --- timed/reports/views.py | 24 +++++++++++++++++++++--- timed/reports/workreport.ots | Bin 12482 -> 12793 bytes 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index e833856de..2a9f1e81b 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -209,6 +209,7 @@ def _create_workreport(self, from_date, to_date, today, project, reports, tmpl = settings.WORK_REPORT_PATH doc = opendoc(tmpl) table = doc.sheets[0] + tasks = defaultdict(int) date_style = table['C5'].style_name # in template cell D3 is empty but styled for float and borders float_style = table['D3'].style_name @@ -229,12 +230,15 @@ def _create_workreport(self, from_date, to_date, today, project, reports, table['C13'] = Cell(report.user.get_full_name(), style_name=text_style) - table['D13'] = Cell(report.comment, style_name=text_style) + table['D13'] = Cell(report.task.name, style_name=text_style) + table['E13'] = Cell(report.comment, style_name=text_style) # when from and to date are None find lowest and biggest date from_date = min(report.date, from_date or date.max) to_date = max(report.date, to_date or date.min) + tasks[report.task.name] += hours + # header values table['C3'] = Cell(customer and customer.name) table['C4'] = Cell(project and project.name) @@ -249,9 +253,23 @@ def _create_workreport(self, from_date, to_date, today, project, reports, table['D4'].style_name = '' table['D8'].style_name = '' + pos = 13 + len(reports) + for task_name, task_total_hours in tasks.items(): + table.insert_rows(pos, 1) + table.row_info(pos).style_name = table.row_info(pos - 1).style_name + table[pos, 0] = Cell( + task_name, + style_name=table[pos - 1, 0].style_name + ) + table[pos, 2] = Cell( + task_total_hours, + style_name=table[pos - 1, 2].style_name + ) + # calculate location of total hours as insert rows moved it - table[13 + len(reports), 2].formula = 'of:=SUM(B13:B{0})'.format( - str(13 + len(reports) - 1)) + table[13 + len(reports) + len(tasks), 2].formula = ( + 'of:=SUM(B13:B{0})'.format(str(13 + len(reports) - 1)) + ) name = self._generate_workreport_name(from_date, today, project) return (name, doc) diff --git a/timed/reports/workreport.ots b/timed/reports/workreport.ots index ae0ed5f160628d4ddf14faf132bf47f4930edbec..a205f11d5760930d0006f37b93dbed2ecde6026a 100644 GIT binary patch literal 12793 zcmb7q1z23mvNaCD-6g@@-Q6v?yE_bSL4t(f?oM!b5AGV=-6goggKNn-@0|PozvkOB zz4y#ov%71itGZUpOM!x+0s%n*0pWT@$#}%)$g%+e0lgismw>Fzt&JVs?2Ps8?5xZU z^&QP^ZRnhBjA(830p zR5GUXw+KK$ZwKZpxssWawSkSkxfOuU@!w5aI~&t4^0H!Z(AdzgAAyq)7gl(E-F!V> zpg>=rnT4a$fq+1PBCh|NNT%0_|1 zL5jgkg-b?CMoP^}#mLD13wuVe-+1cEj?u&Q;nSiRozony>qpLD^0zV%>z@- z!wb!0>#dXP-%H}VDpLEa(|c-iI%^Ain#u<1vPT+nhFVMdS}Vufin=;lMmlSTx>{zs z%6@cL{}^bR@2y+2n!9GV>NnwaXDSR9_18v3y?J<>h@V_<1% zv1@9xbLOCLe!FjOe_&>Fcz$be`CxW=b9r-aWbI&N<9cCZZ+`!3_2<>*boctq;Pzbi z*4)tkZ1>@O@8ROW`oiSK%EI32^x@jb{^s1p#@PAZ{Po$&+S=O2&cW{9_U8Wa!OrIK z{?_5);pWlB`pNyl@!9^x(cZ<~#qsvd&)uuDqr3C%yUW9;+nuNT!?Uxq%j>(#yT`lh z^M||3=jZ1O57HPQAlPaNVF4xAh2v#7c@$NAAiIU;YmLJiQxWT9$Z-a=kV~mj0vzUV+;O5DO5^x`vF(UyaZV6a`;)Xcgn z+dr>7+ay}H#+8k};pn+-PAG+iQA}+ybavKIOPpXL_4bVAqVrsU+Wh`iUmx3*TK`hW zr5p;K=&66E#pssYNb{xDc(n3U8d}aL*C9aiW8&A%cugF+K&z}F9G&znf*dSNOQisX z!T#E;wr|bLW25=1ka8UOP+u@VB{W~v+l(dF@7ebjP8@W8;?5uV>U)DCMv?}*@4!o( zbQk+ci|46;^b_))pei4@b^E@z3TSc0JM7I3;_9o3DxWi*r03DJ;`FKzUeiJQY$2l+ zuLPE@r4FtGQ(iZHEzyh9x?R3;-ot%u7dA>Vg@^v`icdT>$Xm|lN?iHl*l|^2-xQb(2(%QxV zSa};$Mk%;n4l`jQ?1^Ae=4_-evzBjLELgCCDR>CW>D%ILRTJb+T*OmyWFHM!vB6oR z7L(xiiP==G&}g>X#e&1jvgVAX!)QSBVbSqY5IUFAWS_qV>_gAQZ6=s=r5YgO8m8`x z-QO*p=&&7zo(PoDRMO9MV8XTJJvsQ1Z-jINrpAjqzJ{miR(DO#ftGcc7|0C)@PQp0@t>%QWw6WK7M<< zzncRDMZmFy4i!&l*CgLQV&~Elrdho5yk88uE}m#a@ffuU@^-K+LfsNf8Ci+kAq?1% zqeo$LO&w?weD&_pi?$BC(o5Ko9?N6idJ0LnOAoX;%nyLE18enu+QQ=^--_j%q_9JN ziJ;U1H}6dRX2NB*P`TU8*ShNZGFmHU6337$TP2$6g?Wp|hnRZX`~Ve71g#VYD zOE^e?Jl}*~Rv|>3c591EgG`2|2HCa5*B!qO)>{y3K0Hb)Fqm+k_cimdsVq>>S{&wp zt&nOV72;Q(v6ED?Xk)U_o;0`q8K0x2%^bPmkvG1BieLvN5M^*I5BvW3-g!W(qA+lG zSKu@WRc#A?rHRv#psIWJaLt*~9{#dMJ7jyZDcsaf-&zKG1RUlLI~Tl?L53I!Z}1#N zI6i1Gi-9le_RFehP+D3qvjpcU zq{f(=Gd8$Q!Q>WgoT^P;UfiOi`scPf690J0IV0(~VAs<=G�`a4O2s%APMcTZh5^ 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

s0BQ3_`3QRs zdM;7{)1=VaFa;lIE5r=v98#t{N_QQ=2Pfbd5{$4rzG?ybUjbI1CAh9#yBfVsm`gg9d))4v_iZHI2@afrgn9k6mxC zod-2^bch~$&(8e@8ai0n20D^P4N(m-Du%NKrehCu6L_cc6d|0{xp{7|q>CePY3mVi zAQ_utGYI&+6&~@?LBn8Dw(AOSMv!jg&pXn$a`kSl1LT*@;PI77%Z% zuqZ!&LE|Sk5~KiX?K(C5fFo{3BbZ=d({W7oOfDTyyBZG;Ci@jC>3s_ENLtdfBY&(@ zg9tx!F6kPGjmIACtqbPez+GX))Ggm0+dg|$<=$4_gCLCur|d>VCd`PNn+lS zH6KR4I22(@rCpO$HRRcVA|h;noQTl-Z%)G=r8CoSi{$#lzd&P6aDe0zpvZOWiK)87 ziiQEvuMGg1jnf-M=J#>BdliTw>4dTy>G}Zm)%QE=I$v6fZD?krXvdcRefZGUs* z%xHy07w+{iT{-flTe;bt`9g3$5RvJprt1!GA!wuAnIO^m)!qP4u~_)>bdx`MTo~U1 z=?NdZ@8Ze2XaR)Io|ugpgiomMV?3ojghqW7v5T(b%5=`On;?pZb~|yzCp7t!^TyFvlu3&Xoxa|;cqoW3=CG^7+KqskL?K=Mo%+}nb>gm zbK(V3?dkm1_YCN9_hy7x&a46C*5yllI7O>XQ&;X{1mqgFQ_P}u2H|2Ojc0mhtyu=x zy3y`G>JOJH{nIrW&Yl#fTsr#>{g-OU+KreakDuA-O24)*OVk_J%X=296npe(U0=>* z4vVxQ3|Q6>@D8sTMX`#!>rB{^(F?(58WBQ4Gl(Lj7dQZc5#b9+DkY|VbW`02MjJs zikhoOE~VT4!%qWB`I&`1XAm$+t?qZrpXdhFUnCpv2;L_8p2juO@>h**mhivDNwn9A zUPeSofJQ=Al(gVZd23Z1vX+U+D7m0- zD;IYf&bh>6{{5r?I$Wdh=pZN^aCKbQt<~MTJ;zcLAWMg+I$+rtn}a*zT%j{by|gyW z4hlg=At^Qd8dl_6X?@3qvSdv#Yw8*&?lfDVCbdOT-=Oe{X03V>AI9t+z~}H_%+e?# zL@sxcKD==^$7Dm0taD%>O)!j7{`Xezemu)Bx{H0o_Z6fy)bf{cWZfF9yl9xE5({?Vx)Z_vD%@abld{|Ns6cH5_jzuCXHVE-BQ=iSu5Alk3ZzuQ~=XO>?mAfPvK@0Wc={NIw@@0#l$`|u0*&%NEh z&b$7C=a&Zi_bh+Bo4dbY`CXI!d!9E{_Lngr{qJ7=QKS8PrZ<)Lms$P=)Bn*htf(vWzs*ReqT$%zs1zk^V93{xkCTiR5kK{$=}Ae>08COM$sBC$A`zX(4x#=)&tzX4kiSDhh*whBx71*lv@qw4NbmGmpWH~d{5YSW;Nakh z_?Xzpu(*`O$bjVd$mHbYkncrd={2CVf~?&1nC#MsoVt|UvhPKWMKS(`Nx_8~iB)OA zwHe`MKav}NL^T#B6_pg{mDd(jRTR}U)tCKfswwK|==fgVonGAwENmJmX`iZZ?!D8Z7vvYDe3Cym}xKW z>uMkCsvYfaS?DfZ9IW3Suiu?%85$TE8k_E$TIii#9i3hnn_HV)nVnc#ogE*TnwVOd z9bH{p>z&{2o;~XaE+6!-oDDDRjIQjDt{#uA{#;t$ncO-Z-@9JgIbJ!w-JR>*T^Qb5 z9zR;>Ia?e$Ug|knAKhMCyxbZ)-dVcY8N1q_yE<6DJz2axS$#ZPe7;zH__?{ix4Uz2 zd~mYAcYJodzjJo7cY1nyaCUuhb#!$5baTFcdv*SFeeiS(JbQgQyScr+xqo_iyt{q= z^>}~t>-qNW?X97HvH}2ruaOcJQgL57UxQacQ^y4utZTW*_>|d{x5j@`!ur}O#$05L zC-e#RCkmP{c(+i1B)DX$wpdm8J~{4GsyGc8s{arv%}#|)zdxiF3b>7r0ovla4Q@_4 zzkTc!iGjg);QN_qb*0mZ@p6x|*J1kFnnxVt1Iznbb2BTN@4_s^^9{}CW)OkKx2KI6 z-Ive`=*v}Zini03@lR*V_tcaZZOF<*Yj+hyiM3H*OVV4Fo;}cN)W{ zdwEOEnN)JaLziY`yoM4d_3mo&1=qIO!HqJ;b;oua0Wo)RK1`Z+^bUh=%Ewg#7W>Mgi=NZdRMN}D; zTjA=l4<8*{%iw}$csZv%F6uT)2Cq7XDW9|Gb=p|ybIGarToEla!7RN0 zD>6AIPSQ}1$wTXUT7E_jRJ^GEZ4f%?bB_=Uz}^O9`(BAJX-yKU3;@+;HpEd0My27+ z@M+BtS18DT=F7!y(ou^6v>nc#P`ktT@qC<^Mf_0ECxwIBY8NbUH+%N)LqC(^1lsab z0Qiz4$k2X|wUteyGyT)fH2k>=5{|^o`rzQ(i4B667li3w_%cXoI`^!Cjuh@0Yl z_l*=A>Ie~DG<*wF4-U`SXM&QGPorvimScb1$4t`v#2`cekd-ZeTx{*>H;V;B14*f- zsCha*0|t!!k_g`g)sYIEe!p$W4FN)6a!f2*+Nb`!y>EK>4- zYd}3D?0|eV5WDyhRzXpCb7uDeTpth9)x$0INC{kscjI`BQRK0mlLPK^X?Tv|F=DM- z#?D86o$4$9$moy(27O`bDCLMKRc7d8^=5-}a3-8=w6B<~e&FysQOuLV+L=Jq-f)uS z2ENQ{J92!cLU9)?odQxu6%^R?Jz?e2#B5UJMpy)V>tI2N$r1Xesnwxr^kO62R*Rt* zCeB3w`+~nwS_f7?f1e27-?{DGzi`p2o|J#x4SG@9sba-q4xhhF4Vi;&edhkYTDzRc zdGkKzb9Y6q8vB9}FrZ^z1=-Vx&%^2*y6Vg~nWMzc(n*lHa~Zf8wx0|b*_CONLOy;e1mTs6XJNh&=EG$n$VdBn!{Ib%F-#${F=oC4QBIyzh`;o0*%k8LK*d#kA zX$mm36{&Coo2@irH>4d`bU2>SmGcLQYC`tmUG>&aI2dkY({q{_2hPjD@M~l`;SL*L zdg*C7-x7T2z>q7$_hRjixCs0djQ6{^t^$8dRUO2FF*m>l4&EE&YzCU>tb2s=Scf)9 zqcI8TQk@}1eBu-~3P6Cw?0c!dwpjF$A!s%<1besu9<>C`MoaYbs~&u$qZ5$`uaUPZ z8oLS~0`rR$Xv;JH`bmJK_OpT}&C&SB_E)3_Gcq4(`N?yA%j3E*y%)axxzyKf#fz!au$=;fEpF*k3f9Na=ocIF|^F{FAu&hQ<+L0UcF&ejkyYI|M8WVtZQK1 z3NF2z{&=VF2!Vq2WwgFWf{Ousaqg^H+{@Vsg`;d zqu&S*ZKtTbhIX12!gB=jO2powB^hU1i&giWM|BN64IX%Q0j$+sJt-c9SisZrWLm<< z>_yaPs3?^PWc39RQ0Dq-?)imv(<5-IZEtt#LO&CuP|p%&YhP2Aoz-qWU?lEVakc2u zbjP1*v9#-wG5kD^_XZVRw*EnKF`F2@PwxQ5Fpm19D52ACaUeJTuD`l}%`r?W&$RTH zHqo2(IE>93PVvS`?5ZD17Lias81mY9$#!xPT&Zc-rt-?9E@X)aL-8qcurKhZEo}om z%UL?#mQDd+F%K^9D+=C9n=V?<{b<4w z7xa2zL5pyDlC9maqRs=_&f$z2h);J*VSiaR-pTFNE1baZAZnP0ql);-9;U2I^*cn} zvY7BLVMhJMwp~GrAY2vGx;?PRFH)z>O+u^WAi_#FZAZu}h54&;CB-Pd>y|yw)!QIW zF}4H5DDNzb{no{EIPGDCT>_Ou*_-6EpGyDv+}vFEt47?&cjcuJ!}6D@|C_Tf3*E)2Z>?=_6QN@RZC87*v&-GxzS#i#dZL!h2>eN`0C0v4eDVhr;V8YqM zw|8C;`RKh@)^L0$3u|vs{!f!UJA5y_`L9c#mOs&V@$K%*LFI9vSEc2uQ#%>z1@`Tv;G@#* zs|1w5(FCgXX?tC*mvb9Ae5j~q6k;m<-sP$GuqX}~1$gSV8s&Goqy@q%1tO!1PldP$ zQ?u5%5Z0`Dq8sm5!RHfWhyZ<-?;p9p+(=6dIw_tdc&5YX@pn$dvASgq%5ePxJ}osv z(r_jj37l>@t1*D+*LM%%Ioa(es@)g&8Fk#YE46n@T-rAoSGcV^GQ|a@A3M#;sia|v z2;|SvDetCuuz9@>>d1ni&B0BajE&Wro*q3Cz8CIRfANQmrt}(9My!sZ(o zT}AlxEko+mp^X?w)8XUfEjoC+ZapTZ%>jBKIB{^@4l2Y=RqeQB+#22t#m0zntB*

XjNFX~ zrE*H2GmgLnYWH}o^FRLIiq`h-v@vbXw(Y`&-F>qZgXQ(@lDpy{R^qMnRW>-1$7RwS zpQqQLF0$lSPql7*iYX<(y8?AE-;zOAtiIN%;BbWddMWw9pj(Yfjb4NR68fxAPKHAQ!MD|(v z-{z5?SP8HE4GnNa2#KZ#O{gYV)3p@;nY8~id;fn_+Vll3KhykToE?yvQu@8O1xSf~ z5v>q5@K-?mbBhcE1M}w=8H7YZ2wZjC;X?7*tdzWJ={HCt<5hrF9@vf#j5WY>@&MUD zTE~(nlNk>!#*C4tk-G~6CMVQv2;&V(D>1(HJy;{|?vM6|PvE9T%jPh&+yppL) zu)+07eGfx3aD*$A6iXyS2xu@{ROLa98Rs1qN;Q{II^z{959YR2I|S|F>;3V}e}SRy zrc$D}kt7v@SFX|`*-Kj=K}bckFU0A{uGD|{LPTq1=%-PH4?^&8d8a8kmaMp7PSQZY zGqsHJ6g{sEF~N0Q&ZQ{vfWkT{MAUbOkxVKHVY4p#|ON4ZxLMU#XvSw$ubc zm?Eov=Stxu#6hVB%)qs%w%zSj&$v5Jsk4`Yh4^b$tC8T<7z&~o#Yo*QmeHo?JB7

=T6Bprl?K=-f4N!q8r%2S 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 I=>FjTKh%M+g#Z8m From 8ea21fe1cb9b29356b1366d3748248a42c823198 Mon Sep 17 00:00:00 2001 From: Yelin Zhang <30687616+Yelinz@users.noreply.github.com> Date: Tue, 18 Dec 2018 09:27:06 +0100 Subject: [PATCH 559/980] Version bump to 0.12.0 (#359) --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index f323a57be..2c7bffbf8 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.11.0' +__version__ = '0.12.0' From e0219407205816590a88c61e0856092868ffa83b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 28 Dec 2018 16:30:19 +0100 Subject: [PATCH 560/980] Use correct pg data volume mount point --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 63b00d1e6..bbbe8db25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: ports: - "5432:5432" volumes: - - dbdata:/var/lib/postgresq + - dbdata:/var/lib/postgresql/data environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed From fa7214f960d7a108682798a383b1f099867cf98d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 4 Jan 2019 06:30:48 -0800 Subject: [PATCH 561/980] Update django from 1.11.17 to 1.11.18 (#361) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c3e797ca7..ffa17e020 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.7.5 -django==1.11.17 # pyup: >=1.11,<1.12 +django==1.11.18 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 django-multiselectfield==0.1.8 From 56e430c437dbb7fb21a8e128e322cdf5ea9669c4 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 6 Jan 2019 23:53:03 -0800 Subject: [PATCH 562/980] Update pytest-cov from 2.6.0 to 2.6.1 (#363) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e3ea9b4a3..07d804a7b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.11 isort==4.3.4 mockldap==0.3.0 pytest==4.0.2 -pytest-cov==2.6.0 +pytest-cov==2.6.1 pytest-django==3.4.4 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 From 0fe8cfcaadee80ae7f762b2c780bdd9a368f13f2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 7 Jan 2019 00:28:40 -0800 Subject: [PATCH 563/980] Update pytz from 2018.7 to 2018.9 (#364) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ffa17e020..d1680df49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.9.0 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.6.0 psycopg2-binary==2.7.6.1 -pytz==2018.7 +pytz==2018.9 pyexcel-webio==0.1.4 pyexcel-io==0.5.11 django-excel==0.0.10 From 35c64430f9b574edb09002ec635b1b5e9b2b1260 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 7 Jan 2019 23:55:42 -0800 Subject: [PATCH 564/980] Update pytest-django from 3.4.4 to 3.4.5 (#365) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 07d804a7b..a81ceb649 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==4.3.4 mockldap==0.3.0 pytest==4.0.2 pytest-cov==2.6.1 -pytest-django==3.4.4 +pytest-django==3.4.5 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 pytest-mock==1.10.0 From 4eec2925b622da388cfc6ea5b02513a0b662b498 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 8 Jan 2019 00:00:29 -0800 Subject: [PATCH 565/980] Update pytest to 4.1.0 (#362) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index a81ceb649..3e2e70549 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==4.0.2 +pytest==4.1.0 pytest-cov==2.6.1 pytest-django==3.4.5 pytest-env==0.6.2 From 3cd05b561123ffaa257656780cd7795f82d92d66 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 10 Jan 2019 04:05:10 -0800 Subject: [PATCH 566/980] Update django-money from 0.14.3 to 0.14.4 (#366) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d1680df49..5c56b8996 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,6 @@ pyexcel-xlsx==0.5.6 pyexcel-ezodf==0.3.4 django-environ==0.4.5 rest_condition==1.0.3 -django-money==0.14.3 +django-money==0.14.4 python-redmine==2.1.1 uwsgi==2.0.17.1 From 58a179b47716e1e9e01184d486fd1b843c80f42c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 13 Jan 2019 23:55:27 -0800 Subject: [PATCH 567/980] Update python-redmine from 2.1.1 to 2.2.0 (#368) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5c56b8996..a4ae47535 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,5 +17,5 @@ pyexcel-ezodf==0.3.4 django-environ==0.4.5 rest_condition==1.0.3 django-money==0.14.4 -python-redmine==2.1.1 +python-redmine==2.2.0 uwsgi==2.0.17.1 From e06cae3b5977b1a48cb795e6c111b71fba11e7af Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 13 Jan 2019 23:59:28 -0800 Subject: [PATCH 568/980] Update pytest from 4.1.0 to 4.1.1 (#367) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3e2e70549..6f244fe8b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==4.1.0 +pytest==4.1.1 pytest-cov==2.6.1 pytest-django==3.4.5 pytest-env==0.6.2 From f11639762993e46ec2cb4130aeea2bf7abf0323f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 15 Jan 2019 00:03:27 -0800 Subject: [PATCH 569/980] Update djangorestframework-jsonapi from 2.6.0 to 2.7.0 (#369) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a4ae47535..ccf497503 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-filter==2.0.0 django-multiselectfield==0.1.8 djangorestframework==3.9.0 djangorestframework-jwt==1.11.0 -djangorestframework-jsonapi==2.6.0 +djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.6.1 pytz==2018.9 pyexcel-webio==0.1.4 From d5146fb179af105da664c1c32e46810cc1bb0f18 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 15 Jan 2019 16:40:12 +0100 Subject: [PATCH 570/980] Blackify source (#370) --- .flake8 | 29 +- .isort.cfg | 7 +- .travis.yml | 1 + Makefile | 1 + README.md | 1 + requirements-dev.txt | 2 +- setup.py | 87 ++- timed/__init__.py | 2 +- timed/conftest.py | 71 +-- timed/employment/__init__.py | 2 +- timed/employment/admin.py | 112 ++-- timed/employment/apps.py | 4 +- timed/employment/factories.py | 36 +- timed/employment/filters.py | 86 ++- timed/employment/migrations/0001_initial.py | 340 ++++++++--- .../migrations/0002_auto_20170823_1051.py | 16 +- .../migrations/0003_user_tour_done.py | 10 +- .../migrations/0004_auto_20170904_1510.py | 19 +- .../migrations/0005_auto_20170906_1259.py | 16 +- .../migrations/0006_auto_20170906_1635.py | 10 +- .../migrations/0007_auto_20170911_0959.py | 14 +- .../migrations/0008_auto_20171013_1041.py | 12 +- .../migrations/0009_delete_userabsencetype.py | 10 +- .../migrations/0010_overtimecredit_comment.py | 10 +- .../migrations/0011_auto_20171101_1227.py | 12 +- .../migrations/0012_auto_20181026_1528.py | 22 +- timed/employment/models.py | 175 +++--- timed/employment/relations.py | 4 +- timed/employment/serializers.py | 232 ++++--- .../employment/tests/test_absence_balance.py | 181 +++--- timed/employment/tests/test_absence_credit.py | 54 +- timed/employment/tests/test_absence_type.py | 26 +- timed/employment/tests/test_employment.py | 191 ++---- timed/employment/tests/test_location.py | 22 +- .../employment/tests/test_overtime_credit.py | 38 +- timed/employment/tests/test_public_holiday.py | 26 +- timed/employment/tests/test_user.py | 106 ++-- .../employment/tests/test_worktime_balance.py | 145 ++--- timed/employment/urls.py | 26 +- timed/employment/views.py | 173 +++--- timed/mixins.py | 21 +- timed/models.py | 16 +- timed/permissions.py | 9 +- timed/projects/__init__.py | 2 +- timed/projects/admin.py | 52 +- timed/projects/apps.py | 4 +- timed/projects/factories.py | 38 +- timed/projects/filters.py | 41 +- timed/projects/migrations/0001_initial.py | 146 +++-- .../migrations/0002_auto_20170823_1045.py | 18 +- .../migrations/0003_auto_20170831_1624.py | 22 +- .../migrations/0004_auto_20170906_1045.py | 26 +- .../migrations/0005_auto_20170907_0938.py | 54 +- .../migrations/0006_auto_20171010_1423.py | 48 +- .../0007_project_subscription_project.py | 17 +- timed/projects/models.py | 102 ++-- timed/projects/serializers.py | 89 ++- timed/projects/tests/test_billing_type.py | 6 +- timed/projects/tests/test_cost_center.py | 6 +- timed/projects/tests/test_customer.py | 22 +- timed/projects/tests/test_project.py | 47 +- timed/projects/tests/test_task.py | 64 +- timed/projects/urls.py | 10 +- timed/projects/views.py | 38 +- .../management/commands/redmine_report.py | 79 +-- timed/redmine/migrations/0001_initial.py | 29 +- timed/redmine/models.py | 4 +- timed/redmine/tests/test_redmine_report.py | 49 +- .../commands/notify_changed_employments.py | 33 +- .../commands/notify_reviewers_unverified.py | 67 +-- .../commands/notify_supervisors_shorttime.py | 59 +- timed/reports/serializers.py | 38 +- .../reports/tests/test_customer_statistic.py | 69 +-- timed/reports/tests/test_month_statistic.py | 30 +- .../tests/test_notify_changed_employments.py | 17 +- .../tests/test_notify_reviewers_unverified.py | 48 +- .../test_notify_supervisors_shorttime.py | 31 +- timed/reports/tests/test_project_statistic.py | 55 +- timed/reports/tests/test_task_statistic.py | 59 +- timed/reports/tests/test_user_statistic.py | 49 +- timed/reports/tests/test_work_report.py | 84 ++- timed/reports/tests/test_year_statistic.py | 34 +- timed/reports/urls.py | 22 +- timed/reports/views.py | 179 +++--- timed/serializers.py | 8 +- timed/settings.py | 274 ++++----- timed/subscription/admin.py | 12 +- timed/subscription/factories.py | 10 +- timed/subscription/filters.py | 23 +- timed/subscription/migrations/0001_initial.py | 353 ++++++++++- .../migrations/0002_auto_20170808_1729.py | 16 +- .../migrations/0003_auto_20170907_1151.py | 52 +- timed/subscription/models.py | 38 +- timed/subscription/serializers.py | 66 +- timed/subscription/tests/test_order.py | 16 +- timed/subscription/tests/test_package.py | 20 +- .../tests/test_subscription_project.py | 59 +- timed/subscription/urls.py | 10 +- timed/subscription/views.py | 48 +- timed/tests/client.py | 23 +- timed/tests/test_client.py | 9 +- timed/tests/test_settings.py | 6 +- timed/tracking/__init__.py | 2 +- timed/tracking/apps.py | 4 +- timed/tracking/factories.py | 50 +- timed/tracking/filters.py | 99 +-- timed/tracking/migrations/0001_initial.py | 208 +++++-- .../migrations/0002_auto_20170912_1346.py | 26 +- .../migrations/0003_auto_20170912_1347.py | 12 +- .../migrations/0004_auto_20171005_1057.py | 10 +- .../0005_remove_absence_duration.py | 11 +- .../migrations/0006_add_activity_time.py | 16 +- .../0007_migrate_activity_blocks.py | 12 +- .../migrations/0008_delete_activity_blocks.py | 13 +- .../migrations/0009_remove_report_activity.py | 11 +- .../migrations/0010_auto_20180904_0818.py | 12 +- .../migrations/0011_auto_20181026_1528.py | 46 +- timed/tracking/models.py | 125 ++-- timed/tracking/serializers.py | 216 +++---- timed/tracking/tests/test_absence.py | 284 +++------ timed/tracking/tests/test_activity.py | 189 +++--- timed/tracking/tests/test_attendance.py | 56 +- timed/tracking/tests/test_report.py | 564 +++++++----------- timed/tracking/urls.py | 8 +- timed/tracking/views.py | 155 +++-- timed/urls.py | 16 +- timed/wsgi.py | 2 +- 127 files changed, 3650 insertions(+), 3804 deletions(-) diff --git a/.flake8 b/.flake8 index 5f7b1bb96..6cc391dc4 100644 --- a/.flake8 +++ b/.flake8 @@ -1,9 +1,13 @@ [flake8] ignore = - # multiple spaces before operator - E221, - # multiple spaces after separator - E241, + # whitespace before ':' + E203, + # too many leading ### in a block comment + E266, + # line too long (managed by black) + E501, + # Line break occurred before a binary operator (this is not PEP8 compatible) + W503, # Missing docstring in public module D100, # Missing docstring in public class @@ -19,12 +23,11 @@ ignore = # Missing docstring in public package D106, # Missing docstring in __init__ - D107 - -doctests = True -exclude = - manage.py, - Makefile, - migrations, - docs, - __pycache__ + D107, + # needed because of https://github.com/ambv/black/issues/144 + D202, + # other string does contain unindexed parameters + P103 +max-line-length = 80 +exclude = migrations snapshots +max-complexity = 10 diff --git a/.isort.cfg b/.isort.cfg index 94466a960..4ca818cf1 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,3 +1,8 @@ [settings] -skip=migrations +skip=migrations,snapshots known_first_party=timed +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +combine_as_imports=True +line_length=88 diff --git a/.travis.yml b/.travis.yml index a5da66994..65619f3c9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,7 @@ before_script: - psql -c "CREATE DATABASE timed;" -U postgres script: + - black --check . - flake8 - pytest --no-cov-on-fail --cov --create-db diff --git a/Makefile b/Makefile index 8290e868d..c86ad97ba 100644 --- a/Makefile +++ b/Makefile @@ -14,5 +14,6 @@ start: ## Start the development server @docker-compose up -d --build test: ## Test the project + @docker-compose exec backend black --check . @docker-compose exec backend flake8 @docker-compose exec backend pytest --no-cov-on-fail --cov --create-db diff --git a/README.md b/README.md index f7e9076bc..459b9cbf2 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) [![Codecov](https://codecov.io/gh/adfinis-sygroup/timed-backend/branch/master/graph/badge.svg)](https://codecov.io/gh/adfinis-sygroup/timed-backend) [![Pyup](https://pyup.io/repos/github/adfinis-sygroup/timed-backend/shield.svg)](https://pyup.io/account/repos/github/adfinis-sygroup/timed-backend/) +[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/adfinis-sygroup/cookiecutter-django-json-api) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) Timed timetracking software REST API built with Django diff --git a/requirements-dev.txt b/requirements-dev.txt index 6f244fe8b..3521352e5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ -r requirements.txt +black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 flake8==3.5.0 @@ -7,7 +8,6 @@ flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-isort==2.6.0 -flake8-quotes==1.0.0 flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 diff --git a/setup.py b/setup.py index 141503915..ae92609c1 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ from timed import __version__ -with codecs.open('README.md', 'r', encoding='UTF-8') as f: +with codecs.open("README.md", "r", encoding="UTF-8") as f: README_TEXT = f.read() @@ -20,64 +20,57 @@ def find_data(packages, extensions): """ data = defaultdict(list) for package in packages: - package_path = package.replace('.', '/') + package_path = package.replace(".", "/") for dirpath, _, filenames in os.walk(package_path): for filename in filenames: for extension in extensions: - if filename.endswith('.%s' % extension): - file_path = os.path.join( - dirpath, - filename - ) - file_path = file_path[len(package) + 1:] + if filename.endswith(".%s" % extension): + file_path = os.path.join(dirpath, filename) + file_path = file_path[len(package) + 1 :] data[package].append(file_path) return data setup( - name='timed', + name="timed", version=__version__, - author='Adfinis SyGroup AG', - author_email='https://adfinis-sygroup.ch/', - description='Timetracking software', + author="Adfinis SyGroup AG", + author_email="https://adfinis-sygroup.ch/", + description="Timetracking software", long_description=README_TEXT, install_requires=( - 'python-dateutil', - 'django>=1.11', - 'django-auth-ldap', - 'django-filter', - 'django-multiselectfield', - 'djangorestframework', - 'djangorestframework-jsonapi', - 'djangorestframework-jwt', - 'psycopg2' - 'pytz', - 'pyexcel-webio', - 'pyexcel-io', - 'django-excel', - 'pyexcel-ods3', - 'pyexcel-xlsx', - 'pyexcel-ezodf', - 'django-environ', - 'rest_condition', - 'django-money', - 'python-redmine', + "python-dateutil", + "django>=1.11", + "django-auth-ldap", + "django-filter", + "django-multiselectfield", + "djangorestframework", + "djangorestframework-jsonapi", + "djangorestframework-jwt", + "psycopg2" "pytz", + "pyexcel-webio", + "pyexcel-io", + "django-excel", + "pyexcel-ods3", + "pyexcel-xlsx", + "pyexcel-ezodf", + "django-environ", + "rest_condition", + "django-money", + "python-redmine", ), - keywords='timetracking', - url='https://adfinis-sygroup.ch/', + keywords="timetracking", + url="https://adfinis-sygroup.ch/", packages=find_packages(), - package_data=find_data( - find_packages(), ['txt'] - ), + package_data=find_data(find_packages(), ["txt"]), classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'License :: OSI Approved :: ' - 'GNU Affero General Public License v3', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.5', - ] + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: " "GNU Affero General Public License v3", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.5", + ], ) diff --git a/timed/__init__.py b/timed/__init__.py index 2c7bffbf8..ea370a8e5 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = '0.12.0' +__version__ = "0.12.0" diff --git a/timed/conftest.py b/timed/conftest.py index 55f38efd7..b538ba703 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -5,25 +5,28 @@ from timed.tests.client import JSONAPIClient -@pytest.fixture(autouse=True, scope='session') +@pytest.fixture(autouse=True, scope="session") def ldap_directory(): - top = ('o=test', {'o': 'test'}) - people = ('ou=people,o=test', {'ou': 'people'}) - groups = ('ou=groups,o=test', {'ou': 'groups'}) + top = ("o=test", {"o": "test"}) + people = ("ou=people,o=test", {"ou": "people"}) + groups = ("ou=groups,o=test", {"ou": "groups"}) ldapuser = ( - 'uid=ldapuser,ou=people,o=test', { - 'uid': ['ldapuser'], - 'objectClass': [ - 'person', 'organizationalPerson', - 'inetOrgPerson', 'posixAccount' + "uid=ldapuser,ou=people,o=test", + { + "uid": ["ldapuser"], + "objectClass": [ + "person", + "organizationalPerson", + "inetOrgPerson", + "posixAccount", ], - 'userPassword': ['Test1234!'], - 'uidNumber': ['1000'], - 'gidNumber': ['1000'], - 'givenName': ['givenName'], - 'mail': ['ldapuser@example.net'], - 'sn': ['LdapUser'] - } + "userPassword": ["Test1234!"], + "uidNumber": ["1000"], + "gidNumber": ["1000"], + "givenName": ["givenName"], + "mail": ["ldapuser@example.net"], + "sn": ["LdapUser"], + }, ) directory = dict([top, people, groups, ldapuser]) @@ -44,17 +47,17 @@ def client(db): def auth_client(db): """Return instance of a JSONAPIClient that is logged in as test user.""" user = get_user_model().objects.create_user( - username='user', - password='123qweasd', - first_name='Test', - last_name='User', + username="user", + password="123qweasd", + first_name="Test", + last_name="User", is_superuser=False, - is_staff=False + is_staff=False, ) client = JSONAPIClient() client.user = user - client.login('user', '123qweasd') + client.login("user", "123qweasd") return client @@ -62,17 +65,17 @@ def auth_client(db): def admin_client(db): """Return instance of a JSONAPIClient that is logged in as a staff user.""" user = get_user_model().objects.create_user( - username='user', - password='123qweasd', - first_name='Test', - last_name='User', + username="user", + password="123qweasd", + first_name="Test", + last_name="User", is_superuser=False, - is_staff=True + is_staff=True, ) client = JSONAPIClient() client.user = user - client.login('user', '123qweasd') + client.login("user", "123qweasd") return client @@ -80,15 +83,15 @@ def admin_client(db): def superadmin_client(db): """Return instance of a JSONAPIClient that is logged in as superuser.""" user = get_user_model().objects.create_user( - username='user', - password='123qweasd', - first_name='Test', - last_name='User', + username="user", + password="123qweasd", + first_name="Test", + last_name="User", is_staff=True, - is_superuser=True + is_superuser=True, ) client = JSONAPIClient() client.user = user - client.login('user', '123qweasd') + client.login("user", "123qweasd") return client diff --git a/timed/employment/__init__.py b/timed/employment/__init__.py index 9a0f828ec..76788cbf1 100644 --- a/timed/employment/__init__.py +++ b/timed/employment/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -default_app_config = 'timed.employment.apps.EmploymentConfig' +default_app_config = "timed.employment.apps.EmploymentConfig" diff --git a/timed/employment/admin.py b/timed/employment/admin.py index e89148bb6..9a5159e54 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -13,31 +13,29 @@ # do not allow deletion of objects site wide # objects need to be deactivated resp. archived -admin.site.disable_action('delete_selected') +admin.site.disable_action("delete_selected") class SupervisorInline(admin.TabularInline): model = models.User.supervisors.through extra = 0 - fk_name = 'from_user' - verbose_name = _('Supervisor') - verbose_name_plural = _('Supervisors') + fk_name = "from_user" + verbose_name = _("Supervisor") + verbose_name_plural = _("Supervisors") class SuperviseeInline(admin.TabularInline): model = models.User.supervisors.through extra = 0 - fk_name = 'to_user' - verbose_name = _('Supervisee') - verbose_name_plural = _('Supervisees') + fk_name = "to_user" + verbose_name = _("Supervisee") + verbose_name_plural = _("Supervisees") class EmploymentForm(forms.ModelForm): """Custom form for the employment admin.""" - worktime_per_day = DurationInHoursField( - label=_('Worktime per day in hours') - ) + worktime_per_day = DurationInHoursField(label=_("Worktime per day in hours")) def clean(self): """Validate the employment as a whole. @@ -51,39 +49,31 @@ def clean(self): """ data = super().clean() - employments = models.Employment.objects.filter(user=data.get('user')) + employments = models.Employment.objects.filter(user=data.get("user")) if self.instance: employments = employments.exclude(id=self.instance.id) - if ( - data.get('end_date') and - data.get('start_date') >= data.get('end_date') + if data.get("end_date") and data.get("start_date") >= data.get("end_date"): + raise ValidationError(_("The end date must be after the start date")) + + if any( + [ + e.start_date <= (data.get("end_date") or datetime.date.today()) + and data.get("start_date") <= (e.end_date or datetime.date.today()) + for e in employments + ] ): - raise ValidationError(_( - 'The end date must be after the start date' - )) - - if any([ - e.start_date <= ( - data.get('end_date') or - datetime.date.today() - ) and data.get('start_date') <= ( - e.end_date or - datetime.date.today() + raise ValidationError( + _("A user can't have multiple employments at the same time") ) - for e in employments - ]): - raise ValidationError(_( - 'A user can\'t have multiple employments at the same time' - )) return data class Meta: """Meta information for the employment form.""" - fields = '__all__' + fields = "__all__" model = models.Employment @@ -95,7 +85,7 @@ class EmploymentInline(admin.TabularInline): class OvertimeCreditForm(forms.ModelForm): model = models.OvertimeCredit - duration = DurationInHoursField(label=_('Duration in hours')) + duration = DurationInHoursField(label=_("Duration in hours")) class OvertimeCreditInline(admin.TabularInline): @@ -114,48 +104,44 @@ class UserAdmin(UserAdmin): """Timed specific user admin.""" inlines = [ - SupervisorInline, SuperviseeInline, EmploymentInline, - OvertimeCreditInline, AbsenceCreditInline + SupervisorInline, + SuperviseeInline, + EmploymentInline, + OvertimeCreditInline, + AbsenceCreditInline, ] - list_display = ('username', 'first_name', 'last_name', 'is_staff', - 'is_active') + list_display = ("username", "first_name", "last_name", "is_staff", "is_active") actions = [ - 'disable_users', - 'enable_users', - 'disable_staff_status', - 'enable_staff_status' + "disable_users", + "enable_users", + "disable_staff_status", + "enable_staff_status", ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fieldsets += ( - (_('Extra fields'), { - 'fields': [ - 'tour_done', - ], - }), - ) + self.fieldsets += ((_("Extra fields"), {"fields": ["tour_done"]}),) def disable_users(self, request, queryset): queryset.update(is_active=False) - disable_users.short_description = _('Disable selected users') + + disable_users.short_description = _("Disable selected users") def enable_users(self, request, queryset): queryset.update(is_active=True) - enable_users.short_description = _('Enable selected users') + + enable_users.short_description = _("Enable selected users") def disable_staff_status(self, request, queryset): queryset.update(is_staff=False) - disable_staff_status.short_description = _( - 'Disable staff status of selected users' - ) + + disable_staff_status.short_description = _("Disable staff status of selected users") def enable_staff_status(self, request, queryset): queryset.update(is_staff=True) - enable_staff_status.short_description = _( - 'Enable staff status of selected users' - ) + + enable_staff_status.short_description = _("Enable staff status of selected users") def has_delete_permission(self, request, obj=None): return obj and not obj.reports.exists() @@ -165,8 +151,8 @@ def has_delete_permission(self, request, obj=None): class LocationAdmin(admin.ModelAdmin): """Location admin view.""" - list_display = ['name'] - search_fields = ['name'] + list_display = ["name"] + search_fields = ["name"] def has_delete_permission(self, request, obj=None): return obj and not obj.employments.exists() @@ -176,19 +162,15 @@ def has_delete_permission(self, request, obj=None): class PublicHolidayAdmin(admin.ModelAdmin): """Public holiday admin view.""" - list_display = ['__str__', 'date', 'location'] - list_filter = ['location'] + list_display = ["__str__", "date", "location"] + list_filter = ["location"] @admin.register(models.AbsenceType) class AbsenceTypeAdmin(admin.ModelAdmin): """Absence type admin view.""" - list_display = ['name'] + list_display = ["name"] def has_delete_permission(self, request, obj=None): - return ( - obj and - not obj.absences.exists() and - not obj.absencecredit_set.exists() - ) + return obj and not obj.absences.exists() and not obj.absencecredit_set.exists() diff --git a/timed/employment/apps.py b/timed/employment/apps.py index ee0031ac8..be5d2bfb0 100644 --- a/timed/employment/apps.py +++ b/timed/employment/apps.py @@ -6,5 +6,5 @@ class EmploymentConfig(AppConfig): """App configuration for employment app.""" - name = 'timed.employment' - label = 'employment' + name = "timed.employment" + label = "employment" diff --git a/timed/employment/factories.py b/timed/employment/factories.py index 056d5917f..7f14ebd64 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -13,11 +13,11 @@ class UserFactory(DjangoModelFactory): """User factory.""" - first_name = Faker('first_name') - last_name = Faker('last_name') - email = Faker('email') - password = Faker('password', length=12) - username = Faker('user_name') + first_name = Faker("first_name") + last_name = Faker("last_name") + email = Faker("email") + password = Faker("password", length=12) + username = Faker("user_name") class Meta: """Meta informations for the user factory.""" @@ -28,7 +28,7 @@ class Meta: class LocationFactory(DjangoModelFactory): """Location factory.""" - name = Faker('city') + name = Faker("city") class Meta: """Meta informations for the location factory.""" @@ -39,8 +39,8 @@ class Meta: class PublicHolidayFactory(DjangoModelFactory): """Public holiday factory.""" - name = Faker('word') - date = Faker('date_object') + name = Faker("word") + date = Faker("date_object") location = SubFactory(LocationFactory) class Meta: @@ -52,11 +52,11 @@ class Meta: class EmploymentFactory(DjangoModelFactory): """Employment factory.""" - user = SubFactory(UserFactory) - location = SubFactory(LocationFactory) - percentage = Faker('random_int', min=50, max=100) - start_date = Faker('date_object') - end_date = None + user = SubFactory(UserFactory) + location = SubFactory(LocationFactory) + percentage = Faker("random_int", min=50, max=100) + start_date = Faker("date_object") + end_date = None @lazy_attribute def worktime_per_day(self): @@ -76,7 +76,7 @@ class Meta: class AbsenceTypeFactory(DjangoModelFactory): """Absence type factory.""" - name = Faker('word') + name = Faker("word") fill_worktime = False class Meta: @@ -89,9 +89,9 @@ class AbsenceCreditFactory(DjangoModelFactory): """Absence credit factory.""" absence_type = SubFactory(AbsenceTypeFactory) - user = SubFactory(UserFactory) - date = Faker('date_object') - days = Faker('random_int', min=1, max=25) + user = SubFactory(UserFactory) + date = Faker("date_object") + days = Faker("random_int", min=1, max=25) class Meta: """Meta informations for the absence credit factory.""" @@ -103,7 +103,7 @@ class OvertimeCreditFactory(DjangoModelFactory): """Overtime credit factory.""" user = SubFactory(UserFactory) - date = Faker('date_object') + date = Faker("date_object") @lazy_attribute def duration(self): diff --git a/timed/employment/filters.py b/timed/employment/filters.py index ee792b176..99036726b 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -3,8 +3,7 @@ from django.db.models import Value from django.db.models.functions import Coalesce from django_filters.constants import EMPTY_VALUES -from django_filters.rest_framework import (DateFilter, Filter, FilterSet, - NumberFilter) +from django_filters.rest_framework import DateFilter, Filter, FilterSet, NumberFilter from timed.employment import models from timed.employment.models import User @@ -17,40 +16,38 @@ def filter(self, qs, value): if value in EMPTY_VALUES: return qs - return qs.filter(**{ - '%s__year' % self.field_name: value - }) + return qs.filter(**{"%s__year" % self.field_name: value}) class PublicHolidayFilterSet(FilterSet): """Filter set for the public holidays endpoint.""" - year = YearFilter(field_name='date') - from_date = DateFilter(field_name='date', lookup_expr='gte') - to_date = DateFilter(field_name='date', lookup_expr='lte') + year = YearFilter(field_name="date") + from_date = DateFilter(field_name="date", lookup_expr="gte") + to_date = DateFilter(field_name="date", lookup_expr="lte") class Meta: """Meta information for the public holiday filter set.""" - model = models.PublicHoliday - fields = ['year', 'location', 'date', 'from_date', 'to_date'] + model = models.PublicHoliday + fields = ["year", "location", "date", "from_date", "to_date"] class AbsenceTypeFilterSet(FilterSet): - fill_worktime = NumberFilter(field_name='fill_worktime') + fill_worktime = NumberFilter(field_name="fill_worktime") class Meta: """Meta information for the public holiday filter set.""" - model = models.AbsenceType - fields = ['fill_worktime'] + model = models.AbsenceType + fields = ["fill_worktime"] class UserFilterSet(FilterSet): - active = NumberFilter(field_name='is_active') - supervisor = NumberFilter(field_name='supervisors') - is_reviewer = NumberFilter(method='filter_is_reviewer') - is_supervisor = NumberFilter(method='filter_is_supervisor') + active = NumberFilter(field_name="is_active") + supervisor = NumberFilter(field_name="supervisors") + is_reviewer = NumberFilter(method="filter_is_reviewer") + is_supervisor = NumberFilter(method="filter_is_supervisor") def filter_is_reviewer(self, queryset, name, value): if value: @@ -63,67 +60,60 @@ def filter_is_supervisor(self, queryset, name, value): return queryset.exclude(pk__in=User.objects.all_supervisors()) class Meta: - model = models.User - fields = ['active', 'supervisor', 'is_reviewer', 'is_supervisor'] + model = models.User + fields = ["active", "supervisor", "is_reviewer", "is_supervisor"] class EmploymentFilterSet(FilterSet): - date = DateFilter(method='filter_date') + date = DateFilter(method="filter_date") def filter_date(self, queryset, name, value): - queryset = queryset.annotate( - end=Coalesce('end_date', Value(date.today())) - ) + queryset = queryset.annotate(end=Coalesce("end_date", Value(date.today()))) - queryset = queryset.filter( - start_date__lte=value, - end__gte=value - ) + queryset = queryset.filter(start_date__lte=value, end__gte=value) return queryset class Meta: - model = models.Employment - fields = ['user', 'location'] + model = models.Employment + fields = ["user", "location"] class OvertimeCreditFilterSet(FilterSet): - year = YearFilter(field_name='date') - from_date = DateFilter(field_name='date', lookup_expr='gte') - to_date = DateFilter(field_name='date', lookup_expr='lte') + year = YearFilter(field_name="date") + from_date = DateFilter(field_name="date", lookup_expr="gte") + to_date = DateFilter(field_name="date", lookup_expr="lte") class Meta: - model = models.OvertimeCredit - fields = ['year', 'user', 'date', 'from_date', 'to_date'] + model = models.OvertimeCredit + fields = ["year", "user", "date", "from_date", "to_date"] class AbsenceCreditFilterSet(FilterSet): - year = YearFilter(field_name='date') - from_date = DateFilter(field_name='date', lookup_expr='gte') - to_date = DateFilter(field_name='date', lookup_expr='lte') + year = YearFilter(field_name="date") + from_date = DateFilter(field_name="date", lookup_expr="gte") + to_date = DateFilter(field_name="date", lookup_expr="lte") class Meta: - model = models.AbsenceCredit - fields = [ - 'year', 'user', 'date', 'from_date', 'to_date', 'absence_type' - ] + model = models.AbsenceCredit + fields = ["year", "user", "date", "from_date", "to_date", "absence_type"] class WorktimeBalanceFilterSet(FilterSet): - user = NumberFilter(field_name='id') - supervisor = NumberFilter(field_name='supervisors') + user = NumberFilter(field_name="id") + supervisor = NumberFilter(field_name="supervisors") # additional filters analyzed in WorktimeBalanceView # date = DateFilter() # last_reported_date = NumberFilter() class Meta: - model = models.User - fields = ['user'] + model = models.User + fields = ["user"] class AbsenceBalanceFilterSet(FilterSet): - absence_type = NumberFilter(field_name='id') + absence_type = NumberFilter(field_name="id") class Meta: - model = models.AbsenceType - fields = ['absence_type'] + model = models.AbsenceType + fields = ["absence_type"] diff --git a/timed/employment/migrations/0001_initial.py b/timed/employment/migrations/0001_initial.py index ea2e1c261..2e6ecd854 100644 --- a/timed/employment/migrations/0001_initial.py +++ b/timed/employment/migrations/0001_initial.py @@ -16,127 +16,313 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ('auth', '0008_alter_user_username_max_length'), - ] + dependencies = [("auth", "0008_alter_user_username_max_length")] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('supervisors', models.ManyToManyField(related_name='supervisees', to=settings.AUTH_USER_MODEL)), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=30, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "supervisors", + models.ManyToManyField( + related_name="supervisees", to=settings.AUTH_USER_MODEL + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), ], options={ - 'abstract': False, - 'verbose_name_plural': 'users', - 'verbose_name': 'user', + "abstract": False, + "verbose_name_plural": "users", + "verbose_name": "user", }, - managers=[ - ('objects', timed.employment.models.UserManager()), - ], + managers=[("objects", timed.employment.models.UserManager())], ), migrations.CreateModel( - name='AbsenceCredit', + name="AbsenceCredit", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('comment', models.CharField(blank=True, max_length=255)), - ('date', models.DateField()), - ('days', models.PositiveIntegerField(default=0)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("comment", models.CharField(blank=True, max_length=255)), + ("date", models.DateField()), + ("days", models.PositiveIntegerField(default=0)), ], ), migrations.CreateModel( - name='AbsenceType', + name="AbsenceType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), - ('fill_worktime', models.BooleanField(default=False)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50)), + ("fill_worktime", models.BooleanField(default=False)), ], ), migrations.CreateModel( - name='Employment', + name="Employment", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('percentage', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), - ('worktime_per_day', models.DurationField()), - ('start_date', models.DateField()), - ('end_date', models.DateField(blank=True, null=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "percentage", + models.IntegerField( + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(100), + ] + ), + ), + ("worktime_per_day", models.DurationField()), + ("start_date", models.DateField()), + ("end_date", models.DateField(blank=True, null=True)), ], ), migrations.CreateModel( - name='Location', + name="Location", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, unique=True)), - ('workdays', timed.models.WeekdaysField(choices=[(1, 'Monday'), (2, 'Tuesday'), (3, 'Wednesday'), (4, 'Thursday'), (5, 'Friday'), (6, 'Saturday'), (7, 'Sunday')], default=['1', '2', '3', '4', '5'], max_length=13)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50, unique=True)), + ( + "workdays", + timed.models.WeekdaysField( + choices=[ + (1, "Monday"), + (2, "Tuesday"), + (3, "Wednesday"), + (4, "Thursday"), + (5, "Friday"), + (6, "Saturday"), + (7, "Sunday"), + ], + default=["1", "2", "3", "4", "5"], + max_length=13, + ), + ), ], ), migrations.CreateModel( - name='OvertimeCredit', + name="OvertimeCredit", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('duration', models.DurationField(blank=True, null=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='overtime_credits', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateField()), + ("duration", models.DurationField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="overtime_credits", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='PublicHoliday', + name="PublicHoliday", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), - ('date', models.DateField()), - ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='public_holidays', to='employment.Location')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50)), + ("date", models.DateField()), + ( + "location", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="public_holidays", + to="employment.Location", + ), + ), ], ), migrations.AddField( - model_name='employment', - name='location', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.Location'), + model_name="employment", + name="location", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="employment.Location" + ), ), migrations.AddField( - model_name='employment', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employments', to=settings.AUTH_USER_MODEL), + model_name="employment", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="employments", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='absencecredit', - name='absence_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to='employment.AbsenceType'), + model_name="absencecredit", + name="absence_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="absence_credits", + to="employment.AbsenceType", + ), ), migrations.AddField( - model_name='absencecredit', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absence_credits', to=settings.AUTH_USER_MODEL), + model_name="absencecredit", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="absence_credits", + to=settings.AUTH_USER_MODEL, + ), ), migrations.CreateModel( - name='UserAbsenceType', - fields=[ - ], - options={ - 'indexes': [], - 'proxy': True, - }, - bases=('employment.absencetype',), + name="UserAbsenceType", + fields=[], + options={"indexes": [], "proxy": True}, + bases=("employment.absencetype",), ), migrations.AddIndex( - model_name='publicholiday', - index=models.Index(fields=['date'], name='employment__date_2d002c_idx'), + model_name="publicholiday", + index=models.Index(fields=["date"], name="employment__date_2d002c_idx"), ), migrations.AddIndex( - model_name='employment', - index=models.Index(fields=['start_date', 'end_date'], name='employment__start_d_74c274_idx'), + model_name="employment", + index=models.Index( + fields=["start_date", "end_date"], name="employment__start_d_74c274_idx" + ), ), ] diff --git a/timed/employment/migrations/0002_auto_20170823_1051.py b/timed/employment/migrations/0002_auto_20170823_1051.py index 63af80b30..5c6bc0a77 100644 --- a/timed/employment/migrations/0002_auto_20170823_1051.py +++ b/timed/employment/migrations/0002_auto_20170823_1051.py @@ -8,14 +8,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0001_initial'), - ] + dependencies = [("employment", "0001_initial")] operations = [ migrations.AlterField( - model_name='employment', - name='location', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employments', to='employment.Location'), - ), + model_name="employment", + name="location", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="employments", + to="employment.Location", + ), + ) ] diff --git a/timed/employment/migrations/0003_user_tour_done.py b/timed/employment/migrations/0003_user_tour_done.py index 0f51cc5c2..753ad4764 100644 --- a/timed/employment/migrations/0003_user_tour_done.py +++ b/timed/employment/migrations/0003_user_tour_done.py @@ -7,14 +7,12 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0002_auto_20170823_1051'), - ] + dependencies = [("employment", "0002_auto_20170823_1051")] operations = [ migrations.AddField( - model_name='user', - name='tour_done', + model_name="user", + name="tour_done", field=models.BooleanField(default=False), - ), + ) ] diff --git a/timed/employment/migrations/0004_auto_20170904_1510.py b/timed/employment/migrations/0004_auto_20170904_1510.py index 52467d3f2..c84540345 100644 --- a/timed/employment/migrations/0004_auto_20170904_1510.py +++ b/timed/employment/migrations/0004_auto_20170904_1510.py @@ -7,26 +7,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0003_user_tour_done'), - ] + dependencies = [("employment", "0003_user_tour_done")] operations = [ migrations.AlterModelOptions( - name='absencetype', - options={'ordering': ('name',)}, - ), - migrations.AlterModelOptions( - name='location', - options={'ordering': ('name',)}, + name="absencetype", options={"ordering": ("name",)} ), + migrations.AlterModelOptions(name="location", options={"ordering": ("name",)}), migrations.AlterModelOptions( - name='publicholiday', - options={'ordering': ('date',)}, + name="publicholiday", options={"ordering": ("date",)} ), migrations.AlterField( - model_name='absencecredit', - name='days', + model_name="absencecredit", + name="days", field=models.IntegerField(default=0), ), ] diff --git a/timed/employment/migrations/0005_auto_20170906_1259.py b/timed/employment/migrations/0005_auto_20170906_1259.py index 83bc0a60d..f22139732 100644 --- a/timed/employment/migrations/0005_auto_20170906_1259.py +++ b/timed/employment/migrations/0005_auto_20170906_1259.py @@ -8,20 +8,20 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0004_auto_20170904_1510'), - ] + dependencies = [("employment", "0004_auto_20170904_1510")] operations = [ migrations.AddField( - model_name='employment', - name='added', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + model_name="employment", + name="added", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), preserve_default=False, ), migrations.AddField( - model_name='employment', - name='updated', + model_name="employment", + name="updated", field=models.DateTimeField(auto_now=True), ), ] diff --git a/timed/employment/migrations/0006_auto_20170906_1635.py b/timed/employment/migrations/0006_auto_20170906_1635.py index acf6ee825..82761ef09 100644 --- a/timed/employment/migrations/0006_auto_20170906_1635.py +++ b/timed/employment/migrations/0006_auto_20170906_1635.py @@ -8,14 +8,12 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0005_auto_20170906_1259'), - ] + dependencies = [("employment", "0005_auto_20170906_1259")] operations = [ migrations.AlterField( - model_name='overtimecredit', - name='duration', + model_name="overtimecredit", + name="duration", field=models.DurationField(default=datetime.timedelta(0)), - ), + ) ] diff --git a/timed/employment/migrations/0007_auto_20170911_0959.py b/timed/employment/migrations/0007_auto_20170911_0959.py index e6b9d4af7..01d39df6a 100644 --- a/timed/employment/migrations/0007_auto_20170911_0959.py +++ b/timed/employment/migrations/0007_auto_20170911_0959.py @@ -8,14 +8,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0006_auto_20170906_1635'), - ] + dependencies = [("employment", "0006_auto_20170906_1635")] operations = [ migrations.AlterField( - model_name='absencecredit', - name='absence_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employment.AbsenceType'), - ), + model_name="absencecredit", + name="absence_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="employment.AbsenceType" + ), + ) ] diff --git a/timed/employment/migrations/0008_auto_20171013_1041.py b/timed/employment/migrations/0008_auto_20171013_1041.py index b7b8569cc..3867abcc3 100644 --- a/timed/employment/migrations/0008_auto_20171013_1041.py +++ b/timed/employment/migrations/0008_auto_20171013_1041.py @@ -7,14 +7,12 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0007_auto_20170911_0959'), - ] + dependencies = [("employment", "0007_auto_20170911_0959")] operations = [ migrations.AlterField( - model_name='user', - name='last_name', - field=models.CharField(max_length=30, verbose_name='last name'), - ), + model_name="user", + name="last_name", + field=models.CharField(max_length=30, verbose_name="last name"), + ) ] diff --git a/timed/employment/migrations/0009_delete_userabsencetype.py b/timed/employment/migrations/0009_delete_userabsencetype.py index d7377dad4..9714db9fa 100644 --- a/timed/employment/migrations/0009_delete_userabsencetype.py +++ b/timed/employment/migrations/0009_delete_userabsencetype.py @@ -7,12 +7,6 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0008_auto_20171013_1041'), - ] + dependencies = [("employment", "0008_auto_20171013_1041")] - operations = [ - migrations.DeleteModel( - name='UserAbsenceType', - ), - ] + operations = [migrations.DeleteModel(name="UserAbsenceType")] diff --git a/timed/employment/migrations/0010_overtimecredit_comment.py b/timed/employment/migrations/0010_overtimecredit_comment.py index 459692287..144d152b3 100644 --- a/timed/employment/migrations/0010_overtimecredit_comment.py +++ b/timed/employment/migrations/0010_overtimecredit_comment.py @@ -7,14 +7,12 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0009_delete_userabsencetype'), - ] + dependencies = [("employment", "0009_delete_userabsencetype")] operations = [ migrations.AddField( - model_name='overtimecredit', - name='comment', + model_name="overtimecredit", + name="comment", field=models.CharField(blank=True, max_length=255), - ), + ) ] diff --git a/timed/employment/migrations/0011_auto_20171101_1227.py b/timed/employment/migrations/0011_auto_20171101_1227.py index f84f76d44..dd13d03b4 100644 --- a/timed/employment/migrations/0011_auto_20171101_1227.py +++ b/timed/employment/migrations/0011_auto_20171101_1227.py @@ -7,19 +7,17 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0010_overtimecredit_comment'), - ] + dependencies = [("employment", "0010_overtimecredit_comment")] operations = [ migrations.AddField( - model_name='absencecredit', - name='transfer', + model_name="absencecredit", + name="transfer", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='overtimecredit', - name='transfer', + model_name="overtimecredit", + name="transfer", field=models.BooleanField(default=False), ), ] diff --git a/timed/employment/migrations/0012_auto_20181026_1528.py b/timed/employment/migrations/0012_auto_20181026_1528.py index 373085573..e4bb357da 100644 --- a/timed/employment/migrations/0012_auto_20181026_1528.py +++ b/timed/employment/migrations/0012_auto_20181026_1528.py @@ -8,19 +8,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('employment', '0011_auto_20171101_1227'), - ] + dependencies = [("employment", "0011_auto_20171101_1227")] operations = [ migrations.AlterField( - model_name='absencecredit', - name='absence_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='employment.AbsenceType'), + model_name="absencecredit", + name="absence_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="employment.AbsenceType" + ), ), migrations.AlterField( - model_name='employment', - name='location', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employments', to='employment.Location'), + model_name="employment", + name="location", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="employments", + to="employment.Location", + ), ), ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 1b63343a3..6e0161c2f 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -20,7 +20,7 @@ class Location(models.Model): A location is the place where an employee works. """ - name = models.CharField(max_length=50, unique=True) + name = models.CharField(max_length=50, unique=True) workdays = WeekdaysField(default=[str(day) for day in range(1, 6)]) """ Workdays defined per location, default is Monday - Friday @@ -35,7 +35,7 @@ def __str__(self): return self.name class Meta: - ordering = ('name', ) + ordering = ("name",) class PublicHoliday(models.Model): @@ -45,11 +45,11 @@ class PublicHoliday(models.Model): to work. """ - name = models.CharField(max_length=50) - date = models.DateField() - location = models.ForeignKey(Location, - on_delete=models.CASCADE, - related_name='public_holidays') + name = models.CharField(max_length=50) + date = models.DateField() + location = models.ForeignKey( + Location, on_delete=models.CASCADE, related_name="public_holidays" + ) def __str__(self): """Represent the model as a string. @@ -57,13 +57,13 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{0} {1}'.format(self.name, self.date.strftime('%Y')) + return "{0} {1}".format(self.name, self.date.strftime("%Y")) class Meta: """Meta information for the public holiday model.""" - indexes = [models.Index(fields=['date'])] - ordering = ('date', ) + indexes = [models.Index(fields=["date"])] + ordering = ("date",) class AbsenceType(models.Model): @@ -73,7 +73,7 @@ class AbsenceType(models.Model): school. """ - name = models.CharField(max_length=50) + name = models.CharField(max_length=50) fill_worktime = models.BooleanField(default=False) def __str__(self): @@ -94,12 +94,10 @@ def calculate_credit(self, user, start, end): return None credits = AbsenceCredit.objects.filter( - user=user, - absence_type=self, - date__range=[start, end] + user=user, absence_type=self, date__range=[start, end] ) - data = credits.aggregate(credit=Sum('days')) - credit = data['credit'] or 0 + data = credits.aggregate(credit=Sum("days")) + credit = data["credit"] or 0 return credit @@ -113,15 +111,13 @@ def calculate_used_days(self, user, start, end): return None absences = Absence.objects.filter( - user=user, - type=self, - date__range=[start, end] + user=user, type=self, date__range=[start, end] ) used_days = absences.count() return used_days class Meta: - ordering = ('name', ) + ordering = ("name",) class AbsenceCredit(models.Model): @@ -132,14 +128,16 @@ class AbsenceCredit(models.Model): E.g a credit that defines that a user can only have 25 holidays. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='absence_credits') - comment = models.CharField(max_length=255, blank=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="absence_credits", + ) + comment = models.CharField(max_length=255, blank=True) absence_type = models.ForeignKey(AbsenceType, on_delete=models.PROTECT) - date = models.DateField() - days = models.IntegerField(default=0) - transfer = models.BooleanField(default=False) + date = models.DateField() + days = models.IntegerField(default=0) + transfer = models.BooleanField(default=False) """ Mark whether this absence credit is a transfer from last year. """ @@ -152,11 +150,13 @@ class OvertimeCredit(models.Model): added to the worktime of a user. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='overtime_credits') - comment = models.CharField(max_length=255, blank=True) - date = models.DateField() + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="overtime_credits", + ) + comment = models.CharField(max_length=255, blank=True) + date = models.DateField() duration = models.DurationField(default=timedelta(0)) transfer = models.BooleanField(default=False) """ @@ -175,12 +175,9 @@ def get_at(self, user, date): :returns: Employment """ return self.get( - ( - models.Q(end_date__gte=date) | - models.Q(end_date__isnull=True) - ), + (models.Q(end_date__gte=date) | models.Q(end_date__isnull=True)), start_date__lte=date, - user=user + user=user, ) def for_user(self, user, start, end): @@ -195,11 +192,9 @@ def for_user(self, user, start, end): """ # end date NULL on database is like employment is ending today queryset = self.annotate( - end=functions.Coalesce('end_date', models.Value(date.today())) + end=functions.Coalesce("end_date", models.Value(date.today())) ) - return queryset.filter( - user=user - ).exclude( + return queryset.filter(user=user).exclude( models.Q(end__lt=start) | models.Q(start_date__gt=end) ) @@ -211,19 +206,19 @@ class Employment(models.Model): and from when to when. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='employments') - location = models.ForeignKey(Location, - on_delete=models.PROTECT, - related_name='employments') - percentage = models.IntegerField(validators=[ - MinValueValidator(0), - MaxValueValidator(100)]) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="employments" + ) + location = models.ForeignKey( + Location, on_delete=models.PROTECT, related_name="employments" + ) + percentage = models.IntegerField( + validators=[MinValueValidator(0), MaxValueValidator(100)] + ) worktime_per_day = models.DurationField() - start_date = models.DateField() - end_date = models.DateField(blank=True, null=True) - objects = EmploymentManager() + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + objects = EmploymentManager() added = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) @@ -234,10 +229,10 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{0} ({1} - {2})'.format( + return "{0} ({1} - {2})".format( self.user.username, - self.start_date.strftime('%d.%m.%Y'), - self.end_date.strftime('%d.%m.%Y') if self.end_date else 'today' + self.start_date.strftime("%d.%m.%Y"), + self.end_date.strftime("%d.%m.%Y") if self.end_date else "today", ) def calculate_worktime(self, start, end): @@ -271,10 +266,7 @@ def calculate_worktime(self, start, end): # workdays is in isoweekday, byweekday expects Monday to be zero week_workdays = [int(day) - 1 for day in self.location.workdays] workdays = rrule.rrule( - rrule.DAILY, - dtstart=start, - until=end, - byweekday=week_workdays + rrule.DAILY, dtstart=start, until=end, byweekday=week_workdays ).count() # converting workdays as db expects 1 (Sunday) to 7 (Saturday) @@ -287,36 +279,31 @@ def calculate_worktime(self, start, end): location=self.location, date__gte=start, date__lte=end, - date__week_day__in=workdays_db + date__week_day__in=workdays_db, ).count() expected_worktime = self.worktime_per_day * (workdays - holidays) overtime_credit_data = OvertimeCredit.objects.filter( - user=self.user_id, - date__gte=start, - date__lte=end - ).aggregate(total_duration=Sum('duration')) - overtime_credit = overtime_credit_data['total_duration'] or timedelta() + user=self.user_id, date__gte=start, date__lte=end + ).aggregate(total_duration=Sum("duration")) + overtime_credit = overtime_credit_data["total_duration"] or timedelta() reported_worktime_data = Report.objects.filter( - user=self.user_id, - date__gte=start, - date__lte=end - ).aggregate(duration_total=Sum('duration')) - reported_worktime = ( - reported_worktime_data['duration_total'] or timedelta() + user=self.user_id, date__gte=start, date__lte=end + ).aggregate(duration_total=Sum("duration")) + reported_worktime = reported_worktime_data["duration_total"] or timedelta() + + absences = sum( + [ + absence.calculate_duration(self) + for absence in Absence.objects.filter( + user=self.user_id, date__gte=start, date__lte=end + ).select_related("type") + ], + timedelta(), ) - absences = sum([ - absence.calculate_duration(self) - for absence in Absence.objects.filter( - user=self.user_id, - date__gte=start, - date__lte=end, - ).select_related('type') - ], timedelta()) - reported = reported_worktime + absences + overtime_credit return (reported, expected_worktime, reported - expected_worktime) @@ -324,38 +311,40 @@ def calculate_worktime(self, start, end): class Meta: """Meta information for the employment model.""" - indexes = [models.Index(fields=['start_date', 'end_date'])] + indexes = [models.Index(fields=["start_date", "end_date"])] class UserManager(UserManager): def all_supervisors(self): objects = self.model.objects.annotate( - supervisees_count=models.Count('supervisees')) + supervisees_count=models.Count("supervisees") + ) return objects.filter(supervisees_count__gt=0) def all_reviewers(self): - objects = self.model.objects.annotate( - reviews_count=models.Count('reviews')) + objects = self.model.objects.annotate(reviews_count=models.Count("reviews")) return objects.filter(reviews__gt=0) def all_supervisees(self): objects = self.model.objects.annotate( - supervisors_count=models.Count('supervisors')) + supervisors_count=models.Count("supervisors") + ) return objects.filter(supervisors_count__gt=0) class User(AbstractUser): """Timed specific user.""" - supervisors = models.ManyToManyField('self', symmetrical=False, - related_name='supervisees') + supervisors = models.ManyToManyField( + "self", symmetrical=False, related_name="supervisees" + ) tour_done = models.BooleanField(default=False) """ Indicate whether user has finished tour through Timed in frontend. """ - last_name = models.CharField(_('last name'), max_length=30, blank=False) + last_name = models.CharField(_("last name"), max_length=30, blank=False) """ Overwrite last name to make it required as interface relies on it. May also be name of organization if need to. @@ -379,12 +368,12 @@ def calculate_worktime(self, start, end): :returns: tuple of 3 values reported, expected and delta in given time frame """ - employments = Employment.objects.for_user( - self, start, end).select_related('location') + employments = Employment.objects.for_user(self, start, end).select_related( + "location" + ) balances = [ - employment.calculate_worktime(start, end) - for employment in employments + employment.calculate_worktime(start, end) for employment in employments ] reported = sum([balance[0] for balance in balances], timedelta()) diff --git a/timed/employment/relations.py b/timed/employment/relations.py index cdc20befd..2bc6d8d91 100644 --- a/timed/employment/relations.py +++ b/timed/employment/relations.py @@ -7,9 +7,9 @@ class CurrentUserResourceRelatedField(ResourceRelatedField): """User resource related field restricting user to current user.""" def __init__(self, *args, **kwargs): - kwargs['default'] = CurrentUserDefault() + kwargs["default"] = CurrentUserDefault() super().__init__(*args, **kwargs) def get_queryset(self): - request = self.context['request'] + request = self.context["request"] return get_user_model().objects.filter(pk=request.user.pk) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 98a95b2a3..75043b6e4 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -8,9 +8,12 @@ from django.utils.duration import duration_string from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api import relations -from rest_framework_json_api.serializers import (ModelSerializer, Serializer, - SerializerMethodField, - ValidationError) +from rest_framework_json_api.serializers import ( + ModelSerializer, + Serializer, + SerializerMethodField, + ValidationError, +) from timed.employment import models from timed.tracking.models import Absence, Report @@ -19,45 +22,43 @@ class UserSerializer(ModelSerializer): included_serializers = { - 'supervisors': - 'timed.employment.serializers.UserSerializer', - 'supervisees': - 'timed.employment.serializers.UserSerializer', + "supervisors": "timed.employment.serializers.UserSerializer", + "supervisees": "timed.employment.serializers.UserSerializer", } class Meta: """Meta information for the user serializer.""" - model = get_user_model() + model = get_user_model() fields = [ - 'email', - 'first_name', - 'is_active', - 'is_staff', - 'is_superuser', - 'last_name', - 'supervisees', - 'supervisors', - 'tour_done', - 'username', + "email", + "first_name", + "is_active", + "is_staff", + "is_superuser", + "last_name", + "supervisees", + "supervisors", + "tour_done", + "username", ] read_only_fields = [ - 'first_name', - 'is_active', - 'is_staff', - 'is_superuser', - 'last_name', - 'supervisees', - 'supervisors', - 'username', + "first_name", + "is_active", + "is_staff", + "is_superuser", + "last_name", + "supervisees", + "supervisors", + "username", ] class WorktimeBalanceSerializer(Serializer): date = SerializerMethodField() balance = SerializerMethodField() - user = relations.ResourceRelatedField( - model=get_user_model(), read_only=True, source='id' + user = relations.ResourceRelatedField( + model=get_user_model(), read_only=True, source="id" ) def get_date(self, instance): @@ -68,14 +69,15 @@ def get_date(self, instance): return instance.date # calculate last reported day if no specific date is set - max_absence_date = Absence.objects.filter( - user=user, date__lt=today).aggregate(date=Max('date')) - max_report_date = Report.objects.filter( - user=user, date__lt=today).aggregate(date=Max('date')) + max_absence_date = Absence.objects.filter(user=user, date__lt=today).aggregate( + date=Max("date") + ) + max_report_date = Report.objects.filter(user=user, date__lt=today).aggregate( + date=Max("date") + ) last_reported_date = max( - max_absence_date['date'] or date.min, - max_report_date['date'] or date.min + max_absence_date["date"] or date.min, max_report_date["date"] or date.min ) instance.date = last_reported_date @@ -89,33 +91,29 @@ def get_balance(self, instance): _, _, balance = instance.id.calculate_worktime(start, balance_date) return duration_string(balance) - included_serializers = { - 'user': 'timed.employment.serializers.UserSerializer' - } + included_serializers = {"user": "timed.employment.serializers.UserSerializer"} class Meta: - resource_name = 'worktime-balances' + resource_name = "worktime-balances" class AbsenceBalanceSerializer(Serializer): - credit = SerializerMethodField() - used_days = SerializerMethodField() - used_duration = SerializerMethodField() - balance = SerializerMethodField() + credit = SerializerMethodField() + used_days = SerializerMethodField() + used_duration = SerializerMethodField() + balance = SerializerMethodField() - user = relations.ResourceRelatedField( - model=get_user_model(), read_only=True - ) + user = relations.ResourceRelatedField(model=get_user_model(), read_only=True) absence_type = relations.ResourceRelatedField( - model=models.AbsenceType, read_only=True, source='id' + model=models.AbsenceType, read_only=True, source="id" ) absence_credits = relations.SerializerMethodResourceRelatedField( - source='get_absence_credits', + source="get_absence_credits", model=models.AbsenceCredit, many=True, - read_only=True + read_only=True, ) def _get_start(self, instance): @@ -127,8 +125,8 @@ def get_credit(self, instance): For absence types which fill worktime this will be None. """ - if 'credit' in instance: - return instance['credit'] + if "credit" in instance: + return instance["credit"] # id is mapped to absence type absence_type = instance.id @@ -136,10 +134,10 @@ def get_credit(self, instance): start = self._get_start(instance) # avoid multiple calculations as get_balance needs it as well - instance['credit'] = absence_type.calculate_credit( + instance["credit"] = absence_type.calculate_credit( instance.user, start, instance.date ) - return instance['credit'] + return instance["credit"] def get_used_days(self, instance): """ @@ -147,8 +145,8 @@ def get_used_days(self, instance): For absence types which fill worktime this will be None. """ - if 'used_days' in instance: - return instance['used_days'] + if "used_days" in instance: + return instance["used_days"] # id is mapped to absence type absence_type = instance.id @@ -156,10 +154,10 @@ def get_used_days(self, instance): start = self._get_start(instance) # avoid multiple calculations as get_balance needs it as well - instance['used_days'] = absence_type.calculate_used_days( + instance["used_days"] = absence_type.calculate_used_days( instance.user, start, instance.date ) - return instance['used_days'] + return instance["used_days"] def get_used_duration(self, instance): """ @@ -173,24 +171,25 @@ def get_used_duration(self, instance): return None start = self._get_start(instance) - absences = sum([ - absence.calculate_duration( - models.Employment.objects.get_at( - instance.user, absence.date + absences = sum( + [ + absence.calculate_duration( + models.Employment.objects.get_at(instance.user, absence.date) ) - ) - for absence in Absence.objects.filter( - user=instance.user, - date__range=[start, instance.date], - type_id=instance.id - ).select_related('type') - ], timedelta()) + for absence in Absence.objects.filter( + user=instance.user, + date__range=[start, instance.date], + type_id=instance.id, + ).select_related("type") + ], + timedelta(), + ) return duration_string(absences) def get_absence_credits(self, instance): """Get the absence credits for the user and type.""" - if 'absence_credits' in instance: - return instance['absence_credits'] + if "absence_credits" in instance: + return instance["absence_credits"] # id is mapped to absence type absence_type = instance.id @@ -200,10 +199,10 @@ def get_absence_credits(self, instance): absence_type=absence_type, user=instance.user, date__range=[start, instance.date], - ).select_related('user') + ).select_related("user") # avoid multiple calculations when absence credits need to be included - instance['absence_credits'] = absence_credits + instance["absence_credits"] = absence_credits return absence_credits @@ -216,20 +215,18 @@ def get_balance(self, instance): return self.get_credit(instance) - self.get_used_days(instance) included_serializers = { - 'absence_type': - 'timed.employment.serializers.AbsenceTypeSerializer', - 'absence_credits': - 'timed.employment.serializers.AbsenceCreditSerializer', + "absence_type": "timed.employment.serializers.AbsenceTypeSerializer", + "absence_credits": "timed.employment.serializers.AbsenceCreditSerializer", } class Meta: - resource_name = 'absence-balances' + resource_name = "absence-balances" class EmploymentSerializer(ModelSerializer): included_serializers = { - 'user': 'timed.employment.serializers.UserSerializer', - 'location': 'timed.employment.serializers.LocationSerializer' + "user": "timed.employment.serializers.UserSerializer", + "location": "timed.employment.serializers.LocationSerializer", } def validate(self, data): @@ -243,42 +240,37 @@ def validate(self, data): :rtype: dict """ instance = self.instance - start_date = data.get('start_date', instance and instance.start_date) - end_date = data.get('end_date', instance and instance.end_date) + start_date = data.get("start_date", instance and instance.start_date) + end_date = data.get("end_date", instance and instance.end_date) if end_date and start_date >= end_date: - raise ValidationError(_( - 'The end date must be after the start date' - )) + raise ValidationError(_("The end date must be after the start date")) - user = data.get('user', instance and instance.user) + user = data.get("user", instance and instance.user) employments = models.Employment.objects.filter(user=user) # end date not set means employment is ending today end_date = end_date or date.today() employments = employments.annotate( - end=Coalesce('end_date', Value(date.today())) + end=Coalesce("end_date", Value(date.today())) ) if instance: employments = employments.exclude(id=instance.id) - if any([ - e.start_date <= end_date and start_date <= e.end - for e in employments - ]): - raise ValidationError(_( - 'A user can\'t have multiple employments at the same time' - )) + if any([e.start_date <= end_date and start_date <= e.end for e in employments]): + raise ValidationError( + _("A user can't have multiple employments at the same time") + ) return data class Meta: model = models.Employment fields = [ - 'user', - 'location', - 'percentage', - 'worktime_per_day', - 'start_date', - 'end_date', + "user", + "location", + "percentage", + "worktime_per_day", + "start_date", + "end_date", ] @@ -288,8 +280,8 @@ class LocationSerializer(ModelSerializer): class Meta: """Meta information for the location serializer.""" - model = models.Location - fields = ['name', 'workdays'] + model = models.Location + fields = ["name", "workdays"] class PublicHolidaySerializer(ModelSerializer): @@ -298,18 +290,14 @@ class PublicHolidaySerializer(ModelSerializer): location = relations.ResourceRelatedField(read_only=True) included_serializers = { - 'location': 'timed.employment.serializers.LocationSerializer' + "location": "timed.employment.serializers.LocationSerializer" } class Meta: """Meta information for the public holiday serializer.""" - model = models.PublicHoliday - fields = [ - 'name', - 'date', - 'location', - ] + model = models.PublicHoliday + fields = ["name", "date", "location"] class AbsenceTypeSerializer(ModelSerializer): @@ -318,41 +306,25 @@ class AbsenceTypeSerializer(ModelSerializer): class Meta: """Meta information for the absence type serializer.""" - model = models.AbsenceType - fields = [ - 'name', - 'fill_worktime', - ] + model = models.AbsenceType + fields = ["name", "fill_worktime"] class AbsenceCreditSerializer(ModelSerializer): """Absence credit serializer.""" included_serializers = { - 'absence_type': 'timed.employment.serializers.AbsenceTypeSerializer' + "absence_type": "timed.employment.serializers.AbsenceTypeSerializer" } class Meta: """Meta information for the absence credit serializer.""" - model = models.AbsenceCredit - fields = [ - 'user', - 'absence_type', - 'date', - 'days', - 'comment', - 'transfer' - ] + model = models.AbsenceCredit + fields = ["user", "absence_type", "date", "days", "comment", "transfer"] class OvertimeCreditSerializer(ModelSerializer): class Meta: - model = models.OvertimeCredit - fields = [ - 'user', - 'date', - 'duration', - 'comment', - 'transfer' - ] + model = models.OvertimeCredit + fields = ["user", "date", "duration", "comment", "transfer"] diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py index 5e7b189d9..90174ac81 100644 --- a/timed/employment/tests/test_absence_balance.py +++ b/timed/employment/tests/test_absence_balance.py @@ -3,9 +3,12 @@ from django.urls import reverse from rest_framework import status -from timed.employment.factories import (AbsenceCreditFactory, - AbsenceTypeFactory, EmploymentFactory, - UserFactory) +from timed.employment.factories import ( + AbsenceCreditFactory, + AbsenceTypeFactory, + EmploymentFactory, + UserFactory, +) from timed.tracking.factories import AbsenceFactory, ReportFactory @@ -16,55 +19,39 @@ def test_absence_balance_full_day(auth_client, django_assert_num_queries): EmploymentFactory.create(user=user, start_date=day) absence_type = AbsenceTypeFactory.create() - AbsenceCreditFactory.create( - date=day, - user=user, - days=5, - absence_type=absence_type - ) + AbsenceCreditFactory.create(date=day, user=user, days=5, absence_type=absence_type) # credit on different user, may not show up - AbsenceCreditFactory.create( - date=date.today(), - absence_type=absence_type - ) + AbsenceCreditFactory.create(date=date.today(), absence_type=absence_type) - AbsenceFactory.create( - date=day, - user=user, - type=absence_type - ) + AbsenceFactory.create(date=day, user=user, type=absence_type) - AbsenceFactory.create( - date=day - timedelta(days=1), - user=user, - type=absence_type - ) + AbsenceFactory.create(date=day - timedelta(days=1), user=user, type=absence_type) - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") with django_assert_num_queries(7): - result = auth_client.get(url, data={ - 'date': '2017-03-01', - 'user': user.id, - 'include': 'absence_credits,absence_type' - }) + result = auth_client.get( + url, + data={ + "date": "2017-03-01", + "user": user.id, + "include": "absence_credits,absence_type", + }, + ) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - entry = json['data'][0] + assert len(json["data"]) == 1 + entry = json["data"][0] - assert ( - entry['id'] == - '{0}_{1}_2017-03-01'.format(user.id, absence_type.id) - ) - assert entry['attributes']['credit'] == 5 - assert entry['attributes']['used-days'] == 2 - assert entry['attributes']['used-duration'] is None - assert entry['attributes']['balance'] == 3 + assert entry["id"] == "{0}_{1}_2017-03-01".format(user.id, absence_type.id) + assert entry["attributes"]["credit"] == 5 + assert entry["attributes"]["used-days"] == 2 + assert entry["attributes"]["used-duration"] is None + assert entry["attributes"]["balance"] == 3 - assert len(json['included']) == 2 + assert len(json["included"]) == 2 def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): @@ -73,141 +60,125 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): user = UserFactory.create() user.supervisors.add(auth_client.user) EmploymentFactory.create( - user=user, start_date=day, - worktime_per_day=timedelta(hours=5) + user=user, start_date=day, worktime_per_day=timedelta(hours=5) ) absence_type = AbsenceTypeFactory.create(fill_worktime=True) ReportFactory.create( - user=user, - date=day + timedelta(days=1), - duration=timedelta(hours=4) + user=user, date=day + timedelta(days=1), duration=timedelta(hours=4) ) - AbsenceFactory.create(date=day + timedelta(days=1), - user=user, - type=absence_type) + AbsenceFactory.create(date=day + timedelta(days=1), user=user, type=absence_type) - AbsenceFactory.create(date=day, - user=user, - type=absence_type) + AbsenceFactory.create(date=day, user=user, type=absence_type) - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") with django_assert_num_queries(12): - result = auth_client.get(url, data={ - 'date': '2017-03-01', - 'user': user.id, - 'include': 'absence_credits,absence_type' - }) + result = auth_client.get( + url, + data={ + "date": "2017-03-01", + "user": user.id, + "include": "absence_credits,absence_type", + }, + ) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - entry = json['data'][0] + assert len(json["data"]) == 1 + entry = json["data"][0] - assert ( - entry['id'] == - '{0}_{1}_2017-03-01'.format(user.id, absence_type.id) - ) + assert entry["id"] == "{0}_{1}_2017-03-01".format(user.id, absence_type.id) - assert entry['attributes']['credit'] is None - assert entry['attributes']['balance'] is None - assert entry['attributes']['used-days'] is None - assert entry['attributes']['used-duration'] == '06:00:00' + assert entry["attributes"]["credit"] is None + assert entry["attributes"]["balance"] is None + assert entry["attributes"]["used-days"] is None + assert entry["attributes"]["used-duration"] == "06:00:00" def test_absence_balance_detail(auth_client): user = auth_client.user absence_type = AbsenceTypeFactory.create() - url = reverse('absence-balance-detail', args=[ - '{0}_{1}_2017-03-01'.format(user.id, absence_type.id) - ]) + url = reverse( + "absence-balance-detail", + args=["{0}_{1}_2017-03-01".format(user.id, absence_type.id)], + ) result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - entry = json['data'] + entry = json["data"] - assert entry['attributes']['credit'] == 0 - assert entry['attributes']['balance'] == 0 - assert entry['attributes']['used-days'] == 0 - assert entry['attributes']['used-duration'] is None + assert entry["attributes"]["credit"] == 0 + assert entry["attributes"]["balance"] == 0 + assert entry["attributes"]["used-days"] == 0 + assert entry["attributes"]["used-duration"] is None def test_absence_balance_list_none_supervisee(auth_client): - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") AbsenceTypeFactory.create() unrelated_user = UserFactory.create() - result = auth_client.get(url, data={ - 'user': unrelated_user.id, - 'date': '2017-01-03' - }) + result = auth_client.get( + url, data={"user": unrelated_user.id, "date": "2017-01-03"} + ) assert result.status_code == status.HTTP_200_OK - assert len(result.json()['data']) == 0 + assert len(result.json()["data"]) == 0 def test_absence_balance_detail_none_supervisee(auth_client): - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") absence_type = AbsenceTypeFactory.create() unrelated_user = UserFactory.create() - url = reverse('absence-balance-detail', args=[ - '{0}_{1}_2017-03-01'.format(unrelated_user.id, absence_type.id) - ]) + url = reverse( + "absence-balance-detail", + args=["{0}_{1}_2017-03-01".format(unrelated_user.id, absence_type.id)], + ) result = auth_client.get(url) assert result.status_code == status.HTTP_404_NOT_FOUND def test_absence_balance_invalid_date_in_pk(auth_client): - url = reverse('absence-balance-detail', args=['1_2_invalid']) + url = reverse("absence-balance-detail", args=["1_2_invalid"]) result = auth_client.get(url) assert result.status_code == status.HTTP_404_NOT_FOUND def test_absence_balance_invalid_user_in_pk(auth_client): - url = reverse('absence-balance-detail', args=['999999_2_2017-03-01']) + url = reverse("absence-balance-detail", args=["999999_2_2017-03-01"]) result = auth_client.get(url) assert result.status_code == status.HTTP_404_NOT_FOUND def test_absence_balance_no_date(auth_client): - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") - result = auth_client.get(url, data={ - 'user': auth_client.user.id - }) + result = auth_client.get(url, data={"user": auth_client.user.id}) assert result.status_code == status.HTTP_400_BAD_REQUEST def test_absence_balance_invalid_date(auth_client): - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") - result = auth_client.get(url, data={ - 'user': auth_client.user.id, - 'date': 'invalid' - }) + result = auth_client.get(url, data={"user": auth_client.user.id, "date": "invalid"}) assert result.status_code == status.HTTP_400_BAD_REQUEST def test_absence_balance_no_user(auth_client): - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") - result = auth_client.get(url, data={ - 'date': '2017-03-01' - }) + result = auth_client.get(url, data={"date": "2017-03-01"}) assert result.status_code == status.HTTP_400_BAD_REQUEST def test_absence_balance_invalid_user(auth_client): - url = reverse('absence-balance-list') + url = reverse("absence-balance-list") - result = auth_client.get(url, data={ - 'date': '2017-03-01', - 'user': 'invalid' - }) + result = auth_client.get(url, data={"date": "2017-03-01", "user": "invalid"}) assert result.status_code == status.HTTP_400_BAD_REQUEST diff --git a/timed/employment/tests/test_absence_credit.py b/timed/employment/tests/test_absence_credit.py index b7fb3a212..b494f7c2e 100644 --- a/timed/employment/tests/test_absence_credit.py +++ b/timed/employment/tests/test_absence_credit.py @@ -1,12 +1,15 @@ from django.urls import reverse from rest_framework import status -from timed.employment.factories import (AbsenceCreditFactory, - AbsenceTypeFactory, UserFactory) +from timed.employment.factories import ( + AbsenceCreditFactory, + AbsenceTypeFactory, + UserFactory, +) def test_absence_credit_create_authenticated(auth_client): - url = reverse('absence-credit-list') + url = reverse("absence-credit-list") result = auth_client.post(url) assert result.status_code == status.HTTP_403_FORBIDDEN @@ -15,30 +18,19 @@ def test_absence_credit_create_authenticated(auth_client): def test_absence_credit_create_superuser(superadmin_client): absence_type = AbsenceTypeFactory.create() - url = reverse('absence-credit-list') + url = reverse("absence-credit-list") data = { - 'data': { - 'type': 'absence-credits', - 'id': None, - 'attributes': { - 'date': '2017-01-01', - 'duration': '01:00:00', - }, - 'relationships': { - 'user': { - 'data': { - 'type': 'users', - 'id': superadmin_client.user.id - } + "data": { + "type": "absence-credits", + "id": None, + "attributes": {"date": "2017-01-01", "duration": "01:00:00"}, + "relationships": { + "user": {"data": {"type": "users", "id": superadmin_client.user.id}}, + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} }, - 'absence_type': { - 'data': { - 'type': 'absence-types', - 'id': absence_type.id - } - } - } + }, } } @@ -49,24 +41,24 @@ def test_absence_credit_create_superuser(superadmin_client): def test_absence_credit_get_authenticated(auth_client): AbsenceCreditFactory.create_batch(2) absence_credit = AbsenceCreditFactory.create(user=auth_client.user) - url = reverse('absence-credit-list') + url = reverse("absence-credit-list") result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(absence_credit.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(absence_credit.id) def test_absence_credit_get_superuser(superadmin_client): AbsenceCreditFactory.create_batch(2) AbsenceCreditFactory.create(user=superadmin_client.user) - url = reverse('absence-credit-list') + url = reverse("absence-credit-list") result = superadmin_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 3 + assert len(json["data"]) == 3 def test_absence_credit_get_supervisor(auth_client): @@ -76,9 +68,9 @@ def test_absence_credit_get_supervisor(auth_client): AbsenceCreditFactory.create_batch(1) AbsenceCreditFactory.create(user=auth_client.user) AbsenceCreditFactory.create(user=user) - url = reverse('absence-credit-list') + url = reverse("absence-credit-list") result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 2 + assert len(json["data"]) == 2 diff --git a/timed/employment/tests/test_absence_type.py b/timed/employment/tests/test_absence_type.py index a571f09f6..98d3d3e9e 100644 --- a/timed/employment/tests/test_absence_type.py +++ b/timed/employment/tests/test_absence_type.py @@ -7,35 +7,33 @@ def test_absence_type_list(auth_client): AbsenceTypeFactory.create_batch(2) - url = reverse('absence-type-list') + url = reverse("absence-type-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 2 + assert len(json["data"]) == 2 def test_absence_type_list_filter_fill_worktime(auth_client): absence_type = AbsenceTypeFactory.create(fill_worktime=True) AbsenceTypeFactory.create() - url = reverse('absence-type-list') + url = reverse("absence-type-list") - response = auth_client.get(url, data={'fill_worktime': 1}) + response = auth_client.get(url, data={"fill_worktime": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(absence_type.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(absence_type.id) def test_absence_type_detail(auth_client): absence_type = AbsenceTypeFactory.create() - url = reverse('absence-type-detail', args=[ - absence_type.id - ]) + url = reverse("absence-type-detail", args=[absence_type.id]) response = auth_client.get(url) @@ -43,7 +41,7 @@ def test_absence_type_detail(auth_client): def test_absence_type_create(auth_client): - url = reverse('absence-type-list') + url = reverse("absence-type-list") response = auth_client.post(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -52,9 +50,7 @@ def test_absence_type_create(auth_client): def test_absence_type_update(auth_client): absence_type = AbsenceTypeFactory.create() - url = reverse('absence-type-detail', args=[ - absence_type.id - ]) + url = reverse("absence-type-detail", args=[absence_type.id]) response = auth_client.patch(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -63,9 +59,7 @@ def test_absence_type_update(auth_client): def test_absence_type_delete(auth_client): absence_type = AbsenceTypeFactory.create() - url = reverse('absence-type-detail', args=[ - absence_type.id - ]) + url = reverse("absence-type-detail", args=[absence_type.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_employment.py b/timed/employment/tests/test_employment.py index 6961e4c96..97ea8f73d 100644 --- a/timed/employment/tests/test_employment.py +++ b/timed/employment/tests/test_employment.py @@ -8,46 +8,35 @@ from timed.employment import factories from timed.employment.admin import EmploymentForm -from timed.employment.factories import (EmploymentFactory, LocationFactory, - UserFactory) +from timed.employment.factories import EmploymentFactory, LocationFactory, UserFactory from timed.employment.models import Employment from timed.tracking.factories import ReportFactory def test_employment_create_authenticated(auth_client): - url = reverse('employment-list') + url = reverse("employment-list") result = auth_client.post(url) assert result.status_code == status.HTTP_403_FORBIDDEN def test_employment_create_superuser(superadmin_client): - url = reverse('employment-list') + url = reverse("employment-list") location = LocationFactory.create() data = { - 'data': { - 'type': 'employments', - 'id': None, - 'attributes': { - 'percentage': '100', - 'worktime_per_day': '08:00:00', - 'start-date': '2017-04-01', + "data": { + "type": "employments", + "id": None, + "attributes": { + "percentage": "100", + "worktime_per_day": "08:00:00", + "start-date": "2017-04-01", + }, + "relationships": { + "user": {"data": {"type": "users", "id": superadmin_client.user.id}}, + "location": {"data": {"type": "locations", "id": location.id}}, }, - 'relationships': { - 'user': { - 'data': { - 'type': 'users', - 'id': superadmin_client.user.id - } - }, - 'location': { - 'data': { - 'type': 'locations', - 'id': location.id - } - } - } } } @@ -59,17 +48,14 @@ def test_employment_update_end_before_start(superadmin_client): employment = EmploymentFactory.create(user=superadmin_client.user) data = { - 'data': { - 'type': 'employments', - 'id': employment.id, - 'attributes': { - 'start_date': '2017-03-01', - 'end_date': '2017-01-01', - } + "data": { + "type": "employments", + "id": employment.id, + "attributes": {"start_date": "2017-03-01", "end_date": "2017-01-01"}, } } - url = reverse('employment-detail', args=[employment.id]) + url = reverse("employment-detail", args=[employment.id]) result = superadmin_client.patch(url, data) assert result.status_code == status.HTTP_400_BAD_REQUEST @@ -80,16 +66,14 @@ def test_employment_update_overlapping(superadmin_client): employment = EmploymentFactory.create(user=user) data = { - 'data': { - 'type': 'employments', - 'id': employment.id, - 'attributes': { - 'end_date': None, - } + "data": { + "type": "employments", + "id": employment.id, + "attributes": {"end_date": None}, } } - url = reverse('employment-detail', args=[employment.id]) + url = reverse("employment-detail", args=[employment.id]) result = superadmin_client.patch(url, data) assert result.status_code == status.HTTP_400_BAD_REQUEST @@ -98,48 +82,42 @@ def test_employment_list_authenticated(auth_client): EmploymentFactory.create_batch(2) employment = EmploymentFactory.create(user=auth_client.user) - url = reverse('employment-list') + url = reverse("employment-list") result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(employment.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(employment.id) def test_employment_list_superuser(superadmin_client): EmploymentFactory.create_batch(2) EmploymentFactory.create(user=superadmin_client.user) - url = reverse('employment-list') + url = reverse("employment-list") result = superadmin_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 3 + assert len(json["data"]) == 3 def test_employment_list_filter_date(auth_client): EmploymentFactory.create( - user=auth_client.user, - start_date=date(2017, 1, 1,), - end_date=date(2017, 4, 1,) + user=auth_client.user, start_date=date(2017, 1, 1), end_date=date(2017, 4, 1) ) employment = EmploymentFactory.create( - user=auth_client.user, - start_date=date(2017, 4, 2,), - end_date=None + user=auth_client.user, start_date=date(2017, 4, 2), end_date=None ) - url = reverse('employment-list') + url = reverse("employment-list") - result = auth_client.get(url, data={ - 'date': '2017-04-05' - }) + result = auth_client.get(url, data={"date": "2017-04-05"}) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(employment.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(employment.id) def test_employment_list_supervisor(auth_client): @@ -150,12 +128,12 @@ def test_employment_list_supervisor(auth_client): EmploymentFactory.create(user=auth_client.user) EmploymentFactory.create(user=user) - url = reverse('employment-list') + url = reverse("employment-list") result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 2 + assert len(json["data"]) == 2 def test_employment_unique_active(db): @@ -163,9 +141,7 @@ def test_employment_unique_active(db): user = UserFactory.create() EmploymentFactory.create(user=user, end_date=None) employment = EmploymentFactory.create(user=user) - form = EmploymentForm({ - 'end_date': None - }, instance=employment) + form = EmploymentForm({"end_date": None}, instance=employment) with pytest.raises(ValueError): form.save() @@ -173,10 +149,10 @@ def test_employment_unique_active(db): def test_employment_start_before_end(db): employment = EmploymentFactory.create() - form = EmploymentForm({ - 'start_date': date(2009, 1, 1), - 'end_date': date(2016, 1, 1) - }, instance=employment) + form = EmploymentForm( + {"start_date": date(2009, 1, 1), "end_date": date(2016, 1, 1)}, + instance=employment, + ) with pytest.raises(ValueError): form.save() @@ -187,23 +163,14 @@ def test_employment_get_at(db): user = UserFactory.create() employment = EmploymentFactory.create(user=user) - assert ( - Employment.objects.get_at(user, employment.start_date) == - employment - ) + assert Employment.objects.get_at(user, employment.start_date) == employment - employment.end_date = ( - employment.start_date + - timedelta(days=20) - ) + employment.end_date = employment.start_date + timedelta(days=20) employment.save() with pytest.raises(Employment.DoesNotExist): - Employment.objects.get_at( - user, - employment.start_date + timedelta(days=21) - ) + Employment.objects.get_at(user, employment.start_date + timedelta(days=21)) def test_worktime_balance_partial(db): @@ -214,32 +181,24 @@ def test_worktime_balance_partial(db): which is shorter than employment. """ employment = factories.EmploymentFactory.create( - start_date=date(2010, 1, 1), - end_date=None, - worktime_per_day=timedelta(hours=8) + start_date=date(2010, 1, 1), end_date=None, worktime_per_day=timedelta(hours=8) ) user = employment.user # Calculate over one week start = date(2017, 3, 19) - end = date(2017, 3, 26) + end = date(2017, 3, 26) # Overtime credit of 10.5 hours factories.OvertimeCreditFactory.create( - user=user, - date=start, - duration=timedelta(hours=10, minutes=30) + user=user, date=start, duration=timedelta(hours=10, minutes=30) ) # One public holiday during workdays - factories.PublicHolidayFactory.create( - date=start, - location=employment.location - ) + factories.PublicHolidayFactory.create(date=start, location=employment.location) # One public holiday on weekend factories.PublicHolidayFactory.create( - date=start + timedelta(days=1), - location=employment.location + date=start + timedelta(days=1), location=employment.location ) # 5 workdays minus one holiday (32 hours) expected_expected = timedelta(hours=32) @@ -247,9 +206,7 @@ def test_worktime_balance_partial(db): # reported 2 days each 10 hours for day in range(3, 5): ReportFactory.create( - user=user, - date=start + timedelta(days=day), - duration=timedelta(hours=10) + user=user, date=start + timedelta(days=day), duration=timedelta(hours=10) ) # 10 hours reported time + 10.5 overtime credit expected_reported = timedelta(hours=30, minutes=30) @@ -267,36 +224,30 @@ def test_worktime_balance_longer(db): employment = factories.EmploymentFactory.create( start_date=date(2017, 3, 21), end_date=date(2017, 3, 27), - worktime_per_day=timedelta(hours=8) + worktime_per_day=timedelta(hours=8), ) user = employment.user # Calculate over one year start = date(2017, 1, 1) - end = date(2017, 12, 31) + end = date(2017, 12, 31) # Overtime credit of 10.5 hours before employment factories.OvertimeCreditFactory.create( - user=user, - date=start, - duration=timedelta(hours=10, minutes=30) + user=user, date=start, duration=timedelta(hours=10, minutes=30) ) # Overtime credit of during employment factories.OvertimeCreditFactory.create( - user=user, - date=employment.start_date, - duration=timedelta(hours=10, minutes=30) + user=user, date=employment.start_date, duration=timedelta(hours=10, minutes=30) ) # One public holiday during employment factories.PublicHolidayFactory.create( - date=employment.start_date, - location=employment.location + date=employment.start_date, location=employment.location ) # One public holiday before employment started factories.PublicHolidayFactory.create( - date=date(2017, 3, 20), - location=employment.location + date=date(2017, 3, 20), location=employment.location ) # 5 workdays minus one holiday (32 hours) expected_expected = timedelta(hours=32) @@ -306,14 +257,10 @@ def test_worktime_balance_longer(db): ReportFactory.create( user=user, date=employment.start_date + timedelta(days=day), - duration=timedelta(hours=10) + duration=timedelta(hours=10), ) # reported time not on current employment - ReportFactory.create( - user=user, - date=date(2017, 1, 5), - duration=timedelta(hours=10) - ) + ReportFactory.create(user=user, date=date(2017, 1, 5), duration=timedelta(hours=10)) # 10 hours reported time + 10.5 overtime credit expected_reported = timedelta(hours=30, minutes=30) expected_balance = expected_reported - expected_expected @@ -329,31 +276,21 @@ def test_employment_for_user(db): user = factories.UserFactory.create() # employment overlapping time frame (early start) factories.EmploymentFactory.create( - start_date=date(2017, 1, 1), - end_date=date(2017, 2, 28), - user=user + start_date=date(2017, 1, 1), end_date=date(2017, 2, 28), user=user ) # employment overlapping time frame (early end) factories.EmploymentFactory.create( - start_date=date(2017, 3, 1), - end_date=date(2017, 3, 31), - user=user + start_date=date(2017, 3, 1), end_date=date(2017, 3, 31), user=user ) # employment within time frame factories.EmploymentFactory.create( - start_date=date(2017, 4, 1), - end_date=date(2017, 4, 30), - user=user + start_date=date(2017, 4, 1), end_date=date(2017, 4, 30), user=user ) # employment without end date factories.EmploymentFactory.create( - start_date=date(2017, 5, 1), - end_date=None, - user=user + start_date=date(2017, 5, 1), end_date=None, user=user ) - employments = Employment.objects.for_user( - user, date(2017, 2, 1), date(2017, 12, 1) - ) + employments = Employment.objects.for_user(user, date(2017, 2, 1), date(2017, 12, 1)) assert employments.count() == 4 diff --git a/timed/employment/tests/test_location.py b/timed/employment/tests/test_location.py index 5aa46e762..865935c9a 100644 --- a/timed/employment/tests/test_location.py +++ b/timed/employment/tests/test_location.py @@ -6,31 +6,27 @@ def test_location_list(auth_client): LocationFactory.create() - url = reverse('location-list') + url = reverse("location-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] + data = response.json()["data"] assert len(data) == 1 - assert data[0]['attributes']['workdays'] == ( - [str(day) for day in range(1, 6)] - ) + assert data[0]["attributes"]["workdays"] == ([str(day) for day in range(1, 6)]) def test_location_detail(auth_client): location = LocationFactory.create() - url = reverse('location-detail', args=[ - location.id - ]) + url = reverse("location-detail", args=[location.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK def test_location_create(auth_client): - url = reverse('location-list') + url = reverse("location-list") response = auth_client.post(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -39,9 +35,7 @@ def test_location_create(auth_client): def test_location_update(auth_client): location = LocationFactory.create() - url = reverse('location-detail', args=[ - location.id - ]) + url = reverse("location-detail", args=[location.id]) response = auth_client.patch(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -50,9 +44,7 @@ def test_location_update(auth_client): def test_location_delete(auth_client): location = LocationFactory.create() - url = reverse('location-detail', args=[ - location.id - ]) + url = reverse("location-detail", args=[location.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/employment/tests/test_overtime_credit.py b/timed/employment/tests/test_overtime_credit.py index 16c5b62ae..f5ec531b3 100644 --- a/timed/employment/tests/test_overtime_credit.py +++ b/timed/employment/tests/test_overtime_credit.py @@ -7,31 +7,23 @@ def test_overtime_credit_create_authenticated(auth_client): - url = reverse('overtime-credit-list') + url = reverse("overtime-credit-list") result = auth_client.post(url) assert result.status_code == status.HTTP_403_FORBIDDEN def test_overtime_credit_create_superuser(superadmin_client): - url = reverse('overtime-credit-list') + url = reverse("overtime-credit-list") data = { - 'data': { - 'type': 'overtime-credits', - 'id': None, - 'attributes': { - 'date': '2017-01-01', - 'duration': '01:00:00', + "data": { + "type": "overtime-credits", + "id": None, + "attributes": {"date": "2017-01-01", "duration": "01:00:00"}, + "relationships": { + "user": {"data": {"type": "users", "id": superadmin_client.user.id}} }, - 'relationships': { - 'user': { - 'data': { - 'type': 'users', - 'id': superadmin_client.user.id - } - } - } } } @@ -42,24 +34,24 @@ def test_overtime_credit_create_superuser(superadmin_client): def test_overtime_credit_get_authenticated(auth_client): OvertimeCreditFactory.create_batch(2) overtime_credit = OvertimeCreditFactory.create(user=auth_client.user) - url = reverse('overtime-credit-list') + url = reverse("overtime-credit-list") result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(overtime_credit.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(overtime_credit.id) def test_overtime_credit_get_superuser(superadmin_client): OvertimeCreditFactory.create_batch(2) OvertimeCreditFactory.create(user=superadmin_client.user) - url = reverse('overtime-credit-list') + url = reverse("overtime-credit-list") result = superadmin_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 3 + assert len(json["data"]) == 3 def test_overtime_credit_get_supervisor(auth_client): @@ -69,9 +61,9 @@ def test_overtime_credit_get_supervisor(auth_client): OvertimeCreditFactory.create_batch(1) OvertimeCreditFactory.create(user=auth_client.user) OvertimeCreditFactory.create(user=user) - url = reverse('overtime-credit-list') + url = reverse("overtime-credit-list") result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 2 + assert len(json["data"]) == 2 diff --git a/timed/employment/tests/test_public_holiday.py b/timed/employment/tests/test_public_holiday.py index 6ae063d20..299bed043 100644 --- a/timed/employment/tests/test_public_holiday.py +++ b/timed/employment/tests/test_public_holiday.py @@ -8,28 +8,26 @@ def test_public_holiday_list(auth_client): PublicHolidayFactory.create() - url = reverse('public-holiday-list') + url = reverse("public-holiday-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 + assert len(json["data"]) == 1 def test_public_holiday_detail(auth_client): public_holiday = PublicHolidayFactory.create() - url = reverse('public-holiday-detail', args=[ - public_holiday.id - ]) + url = reverse("public-holiday-detail", args=[public_holiday.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK def test_public_holiday_create(auth_client): - url = reverse('public-holiday-list') + url = reverse("public-holiday-list") response = auth_client.post(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -38,9 +36,7 @@ def test_public_holiday_create(auth_client): def test_public_holiday_update(auth_client): public_holiday = PublicHolidayFactory.create() - url = reverse('public-holiday-detail', args=[ - public_holiday.id - ]) + url = reverse("public-holiday-detail", args=[public_holiday.id]) response = auth_client.patch(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -49,9 +45,7 @@ def test_public_holiday_update(auth_client): def test_public_holiday_delete(auth_client): public_holiday = PublicHolidayFactory.create() - url = reverse('public-holiday-detail', args=[ - public_holiday.id - ]) + url = reverse("public-holiday-detail", args=[public_holiday.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -61,11 +55,11 @@ def test_public_holiday_year_filter(auth_client): PublicHolidayFactory.create(date=date(2017, 1, 1)) public_holiday = PublicHolidayFactory.create(date=date(2018, 1, 1)) - url = reverse('public-holiday-list') + url = reverse("public-holiday-list") - response = auth_client.get(url, data={'year': 2018}) + response = auth_client.get(url, data={"year": 2018}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(public_holiday.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(public_holiday.id) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index dfd0c322d..84f64934b 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -5,37 +5,40 @@ from django.urls import reverse from rest_framework import status -from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, - UserFactory) +from timed.employment.factories import ( + AbsenceTypeFactory, + EmploymentFactory, + UserFactory, +) from timed.projects.factories import ProjectFactory from timed.tracking.factories import AbsenceFactory, ReportFactory def test_user_list_unauthenticated(client): - url = reverse('user-list') + url = reverse("user-list") response = client.get(url) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_user_update_unauthenticated(client): user = UserFactory.create() - url = reverse('user-detail', args=[user.id]) + url = reverse("user-detail", args=[user.id]) response = client.patch(url) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_user_login_ldap(client): - client.login('ldapuser', 'Test1234!') - user = get_user_model().objects.get(username='ldapuser') - assert user.first_name == 'givenName' - assert user.last_name == 'LdapUser' - assert user.email == 'ldapuser@example.net' + client.login("ldapuser", "Test1234!") + user = get_user_model().objects.get(username="ldapuser") + assert user.first_name == "givenName" + assert user.last_name == "LdapUser" + assert user.email == "ldapuser@example.net" def test_user_list(auth_client, django_assert_num_queries): UserFactory.create_batch(2) - url = reverse('user-list') + url = reverse("user-list") with django_assert_num_queries(5): response = auth_client.get(url) @@ -43,38 +46,38 @@ def test_user_list(auth_client, django_assert_num_queries): assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 3 + assert len(json["data"]) == 3 def test_user_detail(auth_client): user = auth_client.user - url = reverse('user-detail', args=[user.id]) + url = reverse("user-detail", args=[user.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK def test_user_create_authenticated(auth_client): - url = reverse('user-list') + url = reverse("user-list") response = auth_client.post(url) assert response.status_code == status.HTTP_403_FORBIDDEN def test_user_create_superuser(superadmin_client): - url = reverse('user-list') + url = reverse("user-list") data = { - 'data': { - 'type': 'users', - 'id': None, - 'attributes': { - 'is_staff': True, - 'tour_done': True, - 'email': 'test@example.net', - 'first_name': 'First name', - 'last_name': 'Last name', + "data": { + "type": "users", + "id": None, + "attributes": { + "is_staff": True, + "tour_done": True, + "email": "test@example.net", + "first_name": "First name", + "last_name": "Last name", }, } } @@ -86,19 +89,14 @@ def test_user_create_superuser(superadmin_client): def test_user_update_owner(auth_client): user = auth_client.user data = { - 'data': { - 'type': 'users', - 'id': user.id, - 'attributes': { - 'is_staff': True, - 'tour_done': True - }, + "data": { + "type": "users", + "id": user.id, + "attributes": {"is_staff": True, "tour_done": True}, } } - url = reverse('user-detail', args=[ - user.id - ]) + url = reverse("user-detail", args=[user.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK @@ -111,7 +109,7 @@ def test_user_update_owner(auth_client): def test_user_update_other(auth_client): """User may not change other user.""" user = UserFactory.create() - url = reverse('user-detail', args=[user.id]) + url = reverse("user-detail", args=[user.id]) res = auth_client.patch(url) assert res.status_code == status.HTTP_403_FORBIDDEN @@ -121,7 +119,7 @@ def test_user_delete_authenticated(auth_client): """Should not be able delete a user.""" user = auth_client.user - url = reverse('user-detail', args=[user.id]) + url = reverse("user-detail", args=[user.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -131,7 +129,7 @@ def test_user_delete_superuser(superadmin_client): """Should not be able delete a user.""" user = UserFactory.create() - url = reverse('user-detail', args=[user.id]) + url = reverse("user-detail", args=[user.id]) response = superadmin_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -142,7 +140,7 @@ def test_user_delete_with_reports_superuser(superadmin_client): user = UserFactory.create() ReportFactory.create(user=user) - url = reverse('user-detail', args=[user.id]) + url = reverse("user-detail", args=[user.id]) response = superadmin_client.delete(url) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -157,27 +155,21 @@ def test_user_supervisor_filter(auth_client): auth_client.user.supervisees.add(*supervisees) auth_client.user.save() - res = auth_client.get(reverse('user-list'), { - 'supervisor': auth_client.user.id - }) + res = auth_client.get(reverse("user-list"), {"supervisor": auth_client.user.id}) - assert len(res.json()['data']) == 5 + assert len(res.json()["data"]) == 5 -@pytest.mark.freeze_time('2018-01-07') +@pytest.mark.freeze_time("2018-01-07") def test_user_transfer(superadmin_client): user = UserFactory.create() - EmploymentFactory.create( - user=user, start_date=date(2017, 12, 28), percentage=100 - ) + EmploymentFactory.create(user=user, start_date=date(2017, 12, 28), percentage=100) AbsenceTypeFactory.create(fill_worktime=True) AbsenceTypeFactory.create(fill_worktime=False) absence_type = AbsenceTypeFactory.create(fill_worktime=False) - AbsenceFactory.create( - user=user, type=absence_type, date=date(2017, 12, 29) - ) + AbsenceFactory.create(user=user, type=absence_type, date=date(2017, 12, 29)) - url = reverse('user-transfer', args=[user.id]) + url = reverse("user-transfer", args=[user.id]) response = superadmin_client.post(url) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -190,17 +182,17 @@ def test_user_transfer(superadmin_client): assert overtime_credit.transfer assert overtime_credit.date == date(2018, 1, 1) assert overtime_credit.duration == timedelta(hours=-8, minutes=-30) - assert overtime_credit.comment == 'Transfer 2017' + assert overtime_credit.comment == "Transfer 2017" assert user.absence_credits.count() == 1 absence_credit = user.absence_credits.first() assert absence_credit.transfer assert absence_credit.date == date(2018, 1, 1) assert absence_credit.days == -1 - assert absence_credit.comment == 'Transfer 2017' + assert absence_credit.comment == "Transfer 2017" -@pytest.mark.parametrize('value,expected', [(1, 1), (0, 4)]) +@pytest.mark.parametrize("value,expected", [(1, 1), (0, 4)]) def test_user_is_reviewer_filter(auth_client, value, expected): """Should filter users if they are a reviewer.""" user = UserFactory.create() @@ -209,11 +201,11 @@ def test_user_is_reviewer_filter(auth_client, value, expected): project.reviewers.add(user) - res = auth_client.get(reverse('user-list'), {'is_reviewer': value}) - assert len(res.json()['data']) == expected + res = auth_client.get(reverse("user-list"), {"is_reviewer": value}) + assert len(res.json()["data"]) == expected -@pytest.mark.parametrize('value,expected', [(1, 1), (0, 5)]) +@pytest.mark.parametrize("value,expected", [(1, 1), (0, 5)]) def test_user_is_supervisor_filter(auth_client, value, expected): """Should filter useres if they are a supervisor.""" users = UserFactory.create_batch(2) @@ -221,5 +213,5 @@ def test_user_is_supervisor_filter(auth_client, value, expected): auth_client.user.supervisees.add(*users) - res = auth_client.get(reverse('user-list'), {'is_supervisor': value}) - assert len(res.json()['data']) == expected + res = auth_client.get(reverse("user-list"), {"is_supervisor": value}) + assert len(res.json()["data"]) == expected diff --git a/timed/employment/tests/test_worktime_balance.py b/timed/employment/tests/test_worktime_balance.py index 9cef6d8da..58e1aee29 100644 --- a/timed/employment/tests/test_worktime_balance.py +++ b/timed/employment/tests/test_worktime_balance.py @@ -5,97 +5,89 @@ from django.utils.duration import duration_string from rest_framework import status -from timed.employment.factories import (EmploymentFactory, - OvertimeCreditFactory, - PublicHolidayFactory, UserFactory) +from timed.employment.factories import ( + EmploymentFactory, + OvertimeCreditFactory, + PublicHolidayFactory, + UserFactory, +) from timed.tracking.factories import AbsenceFactory, ReportFactory def test_worktime_balance_create(auth_client): - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") result = auth_client.post(url) assert result.status_code == status.HTTP_405_METHOD_NOT_ALLOWED -def test_worktime_balance_no_employment(auth_client, - django_assert_num_queries): - url = reverse('worktime-balance-list') +def test_worktime_balance_no_employment(auth_client, django_assert_num_queries): + url = reverse("worktime-balance-list") with django_assert_num_queries(4): - result = auth_client.get(url, data={ - 'user': auth_client.user.id, - 'date': '2017-01-01', - }) + result = auth_client.get( + url, data={"user": auth_client.user.id, "date": "2017-01-01"} + ) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - data = json['data'][0] - assert data['id'] == '{0}_2017-01-01'.format(auth_client.user.id) - assert data['attributes']['balance'] == '00:00:00' + assert len(json["data"]) == 1 + data = json["data"][0] + assert data["id"] == "{0}_2017-01-01".format(auth_client.user.id) + assert data["attributes"]["balance"] == "00:00:00" -def test_worktime_balance_with_employments(auth_client, - django_assert_num_queries): +def test_worktime_balance_with_employments(auth_client, django_assert_num_queries): # Calculate over one week start_date = date(2017, 3, 19) - end_date = date(2017, 3, 26) + end_date = date(2017, 3, 26) employment = EmploymentFactory.create( user=auth_client.user, start_date=start_date, worktime_per_day=timedelta(hours=8, minutes=30), - end_date=date(2017, 3, 23) + end_date=date(2017, 3, 23), ) EmploymentFactory.create( user=auth_client.user, start_date=date(2017, 3, 24), worktime_per_day=timedelta(hours=8), - end_date=None + end_date=None, ) # Overtime credit of 10 hours OvertimeCreditFactory.create( - user=auth_client.user, - date=start_date, - duration=timedelta(hours=10, minutes=30) + user=auth_client.user, date=start_date, duration=timedelta(hours=10, minutes=30) ) # One public holiday during workdays - PublicHolidayFactory.create( - date=start_date, - location=employment.location - ) + PublicHolidayFactory.create(date=start_date, location=employment.location) # One public holiday on weekend PublicHolidayFactory.create( - date=start_date + timedelta(days=1), - location=employment.location + date=start_date + timedelta(days=1), location=employment.location ) # 2x 10 hour reported worktime ReportFactory.create( user=auth_client.user, date=start_date + timedelta(days=3), - duration=timedelta(hours=10) + duration=timedelta(hours=10), ) ReportFactory.create( user=auth_client.user, date=start_date + timedelta(days=4), - duration=timedelta(hours=10) + duration=timedelta(hours=10), ) # one absence - AbsenceFactory.create( - user=auth_client.user, - date=start_date + timedelta(days=5) - ) + AbsenceFactory.create(user=auth_client.user, date=start_date + timedelta(days=5)) - url = reverse('worktime-balance-detail', args=[ - '{0}_{1}'.format(auth_client.user.id, end_date.strftime('%Y-%m-%d')) - ]) + url = reverse( + "worktime-balance-detail", + args=["{0}_{1}".format(auth_client.user.id, end_date.strftime("%Y-%m-%d"))], + ) with django_assert_num_queries(12): result = auth_client.get(url) @@ -109,29 +101,29 @@ def test_worktime_balance_with_employments(auth_client, expected_reported = timedelta(hours=28) json = result.json() - assert json['data']['attributes']['balance'] == ( + assert json["data"]["attributes"]["balance"] == ( duration_string(expected_reported - expected_worktime) ) def test_worktime_balance_invalid_pk(auth_client): - url = reverse('worktime-balance-detail', args=['invalid']) + url = reverse("worktime-balance-detail", args=["invalid"]) result = auth_client.get(url) assert result.status_code == status.HTTP_404_NOT_FOUND def test_worktime_balance_no_date(auth_client): - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") result = auth_client.get(url) assert result.status_code == status.HTTP_400_BAD_REQUEST def test_worktime_balance_invalid_date(auth_client): - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") - result = auth_client.get(url, data={'date': 'invalid'}) + result = auth_client.get(url, data={"date": "invalid"}) assert result.status_code == status.HTTP_400_BAD_REQUEST @@ -142,16 +134,14 @@ def test_user_worktime_list_superuser(auth_client): UserFactory.create() auth_client.user.supervisees.add(supervisee) - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") - result = auth_client.get(url, data={ - 'date': '2017-01-01', - }) + result = auth_client.get(url, data={"date": "2017-01-01"}) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 3 + assert len(json["data"]) == 3 def test_worktime_balance_list_supervisor(auth_client): @@ -159,16 +149,14 @@ def test_worktime_balance_list_supervisor(auth_client): UserFactory.create() auth_client.user.supervisees.add(supervisee) - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") - result = auth_client.get(url, data={ - 'date': '2017-01-01', - }) + result = auth_client.get(url, data={"date": "2017-01-01"}) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 2 + assert len(json["data"]) == 2 def test_worktime_balance_list_filter_user(auth_client): @@ -176,38 +164,35 @@ def test_worktime_balance_list_filter_user(auth_client): UserFactory.create() auth_client.user.supervisees.add(supervisee) - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") - result = auth_client.get(url, data={ - 'date': '2017-01-01', - 'user': supervisee.id - }) + result = auth_client.get(url, data={"date": "2017-01-01", "user": supervisee.id}) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 + assert len(json["data"]) == 1 def test_worktime_balance_list_last_reported_date_no_reports( - auth_client, django_assert_num_queries): + auth_client, django_assert_num_queries +): - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") with django_assert_num_queries(2): - result = auth_client.get(url, data={ - 'last_reported_date': 1 - }) + result = auth_client.get(url, data={"last_reported_date": 1}) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 0 + assert len(json["data"]) == 0 -@pytest.mark.freeze_time('2017-02-02') +@pytest.mark.freeze_time("2017-02-02") def test_worktime_balance_list_last_reported_date( - auth_client, django_assert_num_queries): + auth_client, django_assert_num_queries +): EmploymentFactory.create( user=auth_client.user, @@ -217,34 +202,26 @@ def test_worktime_balance_list_last_reported_date( ) ReportFactory.create( - user=auth_client.user, - date=date(2017, 2, 1), - duration=timedelta(hours=10) + user=auth_client.user, date=date(2017, 2, 1), duration=timedelta(hours=10) ) # reports today and in the future should be ignored ReportFactory.create( - user=auth_client.user, - date=date(2017, 2, 2), - duration=timedelta(hours=10) + user=auth_client.user, date=date(2017, 2, 2), duration=timedelta(hours=10) ) ReportFactory.create( - user=auth_client.user, - date=date(2017, 2, 3), - duration=timedelta(hours=10) + user=auth_client.user, date=date(2017, 2, 3), duration=timedelta(hours=10) ) - url = reverse('worktime-balance-list') + url = reverse("worktime-balance-list") with django_assert_num_queries(10): - result = auth_client.get(url, data={ - 'last_reported_date': 1 - }) + result = auth_client.get(url, data={"last_reported_date": 1}) assert result.status_code == status.HTTP_200_OK json = result.json() - assert len(json['data']) == 1 - entry = json['data'][0] - assert entry['attributes']['date'] == '2017-02-01' - assert entry['attributes']['balance'] == '02:00:00' + assert len(json["data"]) == 1 + entry = json["data"][0] + assert entry["attributes"]["date"] == "2017-02-01" + assert entry["attributes"]["balance"] == "02:00:00" diff --git a/timed/employment/urls.py b/timed/employment/urls.py index 4225545c4..5c8a59f52 100644 --- a/timed/employment/urls.py +++ b/timed/employment/urls.py @@ -7,22 +7,14 @@ r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'users', views.UserViewSet, 'user') -r.register(r'employments', views.EmploymentViewSet, 'employment') -r.register(r'locations', views.LocationViewSet, 'location') -r.register(r'public-holidays', views.PublicHolidayViewSet, 'public-holiday') -r.register(r'absence-types', views.AbsenceTypeViewSet, 'absence-type') -r.register(r'overtime-credits', views.OvertimeCreditViewSet, 'overtime-credit') -r.register(r'absence-credits', views.AbsenceCreditViewSet, 'absence-credit') -r.register( - r'worktime-balances', - views.WorktimeBalanceViewSet, - 'worktime-balance' -) -r.register( - r'absence-balances', - views.AbsenceBalanceViewSet, - 'absence-balance' -) +r.register(r"users", views.UserViewSet, "user") +r.register(r"employments", views.EmploymentViewSet, "employment") +r.register(r"locations", views.LocationViewSet, "location") +r.register(r"public-holidays", views.PublicHolidayViewSet, "public-holiday") +r.register(r"absence-types", views.AbsenceTypeViewSet, "absence-type") +r.register(r"overtime-credits", views.OvertimeCreditViewSet, "overtime-credit") +r.register(r"absence-credits", views.AbsenceCreditViewSet, "absence-credit") +r.register(r"worktime-balances", views.WorktimeBalanceViewSet, "worktime-balance") +r.register(r"absence-balances", views.AbsenceBalanceViewSet, "absence-balance") urlpatterns = r.urls diff --git a/timed/employment/views.py b/timed/employment/views.py index d0b572555..689cfdbbb 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -14,9 +14,16 @@ from timed.employment import filters, models, serializers from timed.employment.permissions import NoReports from timed.mixins import AggregateQuerysetMixin -from timed.permissions import (IsAuthenticated, IsCreateOnly, IsDeleteOnly, - IsOwner, IsReadOnly, IsSuperUser, IsSupervisor, - IsUpdateOnly) +from timed.permissions import ( + IsAuthenticated, + IsCreateOnly, + IsDeleteOnly, + IsOwner, + IsReadOnly, + IsSuperUser, + IsSupervisor, + IsUpdateOnly, +) from timed.tracking.models import Absence, Report @@ -30,25 +37,28 @@ class UserViewSet(ModelViewSet): permission_classes = [ # only owner, superuser and supervisor may update user - (C(IsOwner) | C(IsSuperUser) | C(IsSupervisor)) & C(IsUpdateOnly) | + (C(IsOwner) | C(IsSuperUser) | C(IsSupervisor)) & C(IsUpdateOnly) + | # only superuser may delete users without reports - C(IsSuperUser) & C(IsDeleteOnly) & C(NoReports) | + C(IsSuperUser) & C(IsDeleteOnly) & C(NoReports) + | # only superuser may create users - C(IsSuperUser) & C(IsCreateOnly) | + C(IsSuperUser) & C(IsCreateOnly) + | # all authenticated users may read C(IsAuthenticated) & C(IsReadOnly) ] serializer_class = serializers.UserSerializer filterset_class = filters.UserFilterSet - search_fields = ('username', 'first_name', 'last_name') + search_fields = ("username", "first_name", "last_name") def get_queryset(self): return get_user_model().objects.prefetch_related( - 'employments', 'supervisees', 'supervisors' + "employments", "supervisees", "supervisors" ) - @action(methods=['post'], detail=True) + @action(methods=["post"], detail=True) def transfer(self, request, pk=None): """ Transfer worktime and absence balance to new year. @@ -65,10 +75,11 @@ def transfer(self, request, pk=None): # transfer absence types transfered_absence_credits = user.absence_credits.filter( - date=start_year, transfer=True) - types = models.AbsenceType.objects.filter( - fill_worktime=False - ).exclude(id__in=transfered_absence_credits.values('absence_type')) + date=start_year, transfer=True + ) + types = models.AbsenceType.objects.filter(fill_worktime=False).exclude( + id__in=transfered_absence_credits.values("absence_type") + ) for absence_type in types: credit = absence_type.calculate_credit(user, start, end) used_days = absence_type.calculate_used_days(user, start, end) @@ -77,24 +88,22 @@ def transfer(self, request, pk=None): models.AbsenceCredit.objects.create( absence_type=absence_type, user=user, - comment=_('Transfer %(year)s') % {'year': year - 1}, + comment=_("Transfer %(year)s") % {"year": year - 1}, date=start_year, days=balance, - transfer=True + transfer=True, ) # transfer overtime - overtime_credit = user.overtime_credits.filter( - date=start_year, transfer=True - ) + overtime_credit = user.overtime_credits.filter(date=start_year, transfer=True) if not overtime_credit.exists(): reported, expected, delta = user.calculate_worktime(start, end) models.OvertimeCredit.objects.create( user=user, - comment=_('Transfer %(year)s') % {'year': year - 1}, + comment=_("Transfer %(year)s") % {"year": year - 1}, date=start_year, duration=delta, - transfer=True + transfer=True, ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -113,14 +122,12 @@ def _extract_date(self): In detail route extract it from pk and it list from query params. """ - pk = self.request.parser_context['kwargs'].get('pk') + pk = self.request.parser_context["kwargs"].get("pk") # detail case if pk is not None: try: - return datetime.datetime.strptime( - pk.split('_')[1], '%Y-%m-%d' - ) + return datetime.datetime.strptime(pk.split("_")[1], "%Y-%m-%d") except (ValueError, TypeError, IndexError): raise exceptions.NotFound() @@ -129,44 +136,35 @@ def _extract_date(self): query_params = self.request.query_params try: return datetime.datetime.strptime( - query_params.get('date'), '%Y-%m-%d' + query_params.get("date"), "%Y-%m-%d" ).date() except ValueError: - raise exceptions.ParseError(_('Date is invalid')) + raise exceptions.ParseError(_("Date is invalid")) except TypeError: - if query_params.get('last_reported_date', '0') == '0': - raise exceptions.ParseError(_('Date filter needs to be set')) + if query_params.get("last_reported_date", "0") == "0": + raise exceptions.ParseError(_("Date filter needs to be set")) return None def get_queryset(self): date = self._extract_date() user = self.request.user - queryset = get_user_model().objects.values('id') - queryset = queryset.annotate( - date=Value(date, DateField()), - ) + queryset = get_user_model().objects.values("id") + queryset = queryset.annotate(date=Value(date, DateField())) # last_reported_date filter is set, a date can only be calucated # for users with either at least one absence or report if date is None: - users_with_reports = Report.objects.values('user').distinct() - users_with_absences = Absence.objects.values('user').distinct() + users_with_reports = Report.objects.values("user").distinct() + users_with_absences = Absence.objects.values("user").distinct() active_users = users_with_reports.union(users_with_absences) queryset = queryset.filter(id__in=active_users) queryset = queryset.annotate( - pk=Concat( - 'id', - Value('_'), - 'date', - output_field=CharField() - ) + pk=Concat("id", Value("_"), "date", output_field=CharField()) ) if not user.is_superuser: - queryset = queryset.filter( - Q(id=user.id) | Q(supervisors=user) - ) + queryset = queryset.filter(Q(id=user.id) | Q(supervisors=user)) return queryset @@ -184,14 +182,12 @@ def _extract_date(self): In detail route extract it from pk and it list from query params. """ - pk = self.request.parser_context['kwargs'].get('pk') + pk = self.request.parser_context["kwargs"].get("pk") # detail case if pk is not None: try: - return datetime.datetime.strptime( - pk.split('_')[2], '%Y-%m-%d' - ) + return datetime.datetime.strptime(pk.split("_")[2], "%Y-%m-%d") except (ValueError, TypeError, IndexError): raise exceptions.NotFound() @@ -199,13 +195,12 @@ def _extract_date(self): # list case try: return datetime.datetime.strptime( - self.request.query_params.get('date'), - '%Y-%m-%d' + self.request.query_params.get("date"), "%Y-%m-%d" ).date() except ValueError: - raise exceptions.ParseError(_('Date is invalid')) + raise exceptions.ParseError(_("Date is invalid")) except TypeError: - raise exceptions.ParseError(_('Date filter needs to be set')) + raise exceptions.ParseError(_("Date filter needs to be set")) def _extract_user(self): """ @@ -214,24 +209,24 @@ def _extract_user(self): In detail route extract it from pk and it list from query params. """ - pk = self.request.parser_context['kwargs'].get('pk') + pk = self.request.parser_context["kwargs"].get("pk") # detail case if pk is not None: try: - user_id = int(pk.split('_')[0]) + user_id = int(pk.split("_")[0]) # avoid query if user is self if self.request.user.id == user_id: return self.request.user - return get_user_model().objects.get(pk=pk.split('_')[0]) + return get_user_model().objects.get(pk=pk.split("_")[0]) except (ValueError, get_user_model().DoesNotExist): raise exceptions.NotFound() # list case try: - user_id = self.request.query_params.get('user') + user_id = self.request.query_params.get("user") if user_id is None: - raise exceptions.ParseError(_('User filter needs to be set')) + raise exceptions.ParseError(_("User filter needs to be set")) # avoid query if user is self if self.request.user.id == int(user_id): @@ -239,27 +234,18 @@ def _extract_user(self): return get_user_model().objects.get(pk=user_id) except (ValueError, get_user_model().DoesNotExist): - raise exceptions.ParseError(_('User is invalid')) + raise exceptions.ParseError(_("User is invalid")) def get_queryset(self): date = self._extract_date() user = self._extract_user() - queryset = models.AbsenceType.objects.values('id') - queryset = queryset.annotate( - date=Value(date, DateField()), - ) - queryset = queryset.annotate( - user=Value(user.id, IntegerField()), - ) + queryset = models.AbsenceType.objects.values("id") + queryset = queryset.annotate(date=Value(date, DateField())) + queryset = queryset.annotate(user=Value(user.id, IntegerField())) queryset = queryset.annotate( pk=Concat( - 'user', - Value('_'), - 'id', - Value('_'), - 'date', - output_field=CharField() + "user", Value("_"), "id", Value("_"), "date", output_field=CharField() ) ) @@ -276,11 +262,12 @@ def get_queryset(self): class EmploymentViewSet(ModelViewSet): serializer_class = serializers.EmploymentSerializer - ordering = ('-end_date',) + ordering = ("-end_date",) filterset_class = filters.EmploymentFilterSet permission_classes = [ # super user can add/read overtime credits - C(IsAuthenticated) & C(IsSuperUser) | + C(IsAuthenticated) & C(IsSuperUser) + | # user may only read filtered results C(IsAuthenticated) & C(IsReadOnly) ] @@ -296,12 +283,10 @@ def get_queryset(self): """ user = self.request.user - queryset = models.Employment.objects.select_related('user', 'location') + queryset = models.Employment.objects.select_related("user", "location") if not user.is_superuser: - queryset = queryset.filter( - Q(user=user) | Q(user__supervisors=user) - ) + queryset = queryset.filter(Q(user=user) | Q(user__supervisors=user)) return queryset @@ -309,17 +294,17 @@ def get_queryset(self): class LocationViewSet(ReadOnlyModelViewSet): """Location viewset set.""" - queryset = models.Location.objects.all() + queryset = models.Location.objects.all() serializer_class = serializers.LocationSerializer - ordering = ('name',) + ordering = ("name",) class PublicHolidayViewSet(ReadOnlyModelViewSet): """Public holiday view set.""" serializer_class = serializers.PublicHolidaySerializer - filterset_class = filters.PublicHolidayFilterSet - ordering = ('date',) + filterset_class = filters.PublicHolidayFilterSet + ordering = ("date",) def get_queryset(self): """Prefetch the related data. @@ -327,18 +312,16 @@ def get_queryset(self): :return: The public holidays :rtype: QuerySet """ - return models.PublicHoliday.objects.select_related( - 'location' - ) + return models.PublicHoliday.objects.select_related("location") class AbsenceTypeViewSet(ReadOnlyModelViewSet): """Absence type view set.""" - queryset = models.AbsenceType.objects.all() + queryset = models.AbsenceType.objects.all() serializer_class = serializers.AbsenceTypeSerializer - filterset_class = filters.AbsenceTypeFilterSet - ordering = ('name',) + filterset_class = filters.AbsenceTypeFilterSet + ordering = ("name",) class AbsenceCreditViewSet(ModelViewSet): @@ -348,7 +331,8 @@ class AbsenceCreditViewSet(ModelViewSet): serializer_class = serializers.AbsenceCreditSerializer permission_classes = [ # super user can add/read absence credits - C(IsAuthenticated) & C(IsSuperUser) | + C(IsAuthenticated) & C(IsSuperUser) + | # user may only read filtered results C(IsAuthenticated) & C(IsReadOnly) ] @@ -364,12 +348,10 @@ def get_queryset(self): """ user = self.request.user - queryset = models.AbsenceCredit.objects.select_related('user') + queryset = models.AbsenceCredit.objects.select_related("user") if not user.is_superuser: - queryset = queryset.filter( - Q(user=user) | Q(user__supervisors=user) - ) + queryset = queryset.filter(Q(user=user) | Q(user__supervisors=user)) return queryset @@ -381,7 +363,8 @@ class OvertimeCreditViewSet(ModelViewSet): serializer_class = serializers.OvertimeCreditSerializer permission_classes = [ # super user can add/read overtime credits - C(IsAuthenticated) & C(IsSuperUser) | + C(IsAuthenticated) & C(IsSuperUser) + | # user may only read filtered results C(IsAuthenticated) & C(IsReadOnly) ] @@ -397,11 +380,9 @@ def get_queryset(self): """ user = self.request.user - queryset = models.OvertimeCredit.objects.select_related('user') + queryset = models.OvertimeCredit.objects.select_related("user") if not user.is_superuser: - queryset = queryset.filter( - Q(user=user) | Q(user__supervisors=user) - ) + queryset = queryset.filter(Q(user=user) | Q(user__supervisors=user)) return queryset diff --git a/timed/mixins.py b/timed/mixins.py index 72ebb84d3..68d3b5f78 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -36,9 +36,8 @@ def _is_related_field(self, val): Ignores serializer method fields which define logic separately. """ - return ( - isinstance(val, relations.ResourceRelatedField) and - not isinstance(val, relations.SerializerMethodResourceRelatedField) + return isinstance(val, relations.ResourceRelatedField) and not isinstance( + val, relations.SerializerMethodResourceRelatedField ) def get_serializer(self, data, *args, **kwargs): @@ -46,7 +45,7 @@ def get_serializer(self, data, *args, **kwargs): if not data: return super().get_serializer(data, *args, **kwargs) - many = kwargs.get('many') + many = kwargs.get("many") if not many: data = [data] @@ -63,7 +62,7 @@ def get_serializer(self, data, *args, **kwargs): qs = value.model.objects.filter(id__in=obj_ids) qs = qs.select_related() - if hasattr(self, 'prefetch_related_for_field'): + if hasattr(self, "prefetch_related_for_field"): qs = qs.prefetch_related( *self.prefetch_related_for_field.get(source, []) ) @@ -73,13 +72,15 @@ def get_serializer(self, data, *args, **kwargs): # enhance entry dicts with model instances data = [ - AggregateObject(**{ - **entry, + AggregateObject( **{ - field: objects[entry[field]] - for field, objects in prefetch_per_field.items() + **entry, + **{ + field: objects[entry[field]] + for field, objects in prefetch_per_field.items() + }, } - }) + ) for entry in data ] diff --git a/timed/models.py b/timed/models.py index 0c7579121..0d41ca2d9 100644 --- a/timed/models.py +++ b/timed/models.py @@ -14,16 +14,16 @@ class WeekdaysField(MultiSelectField): MO, TU, WE, TH, FR, SA, SU = range(1, 8) WEEKDAYS = ( - (MO, _('Monday')), - (TU, _('Tuesday')), - (WE, _('Wednesday')), - (TH, _('Thursday')), - (FR, _('Friday')), - (SA, _('Saturday')), - (SU, _('Sunday')) + (MO, _("Monday")), + (TU, _("Tuesday")), + (WE, _("Wednesday")), + (TH, _("Thursday")), + (FR, _("Friday")), + (SA, _("Saturday")), + (SU, _("Sunday")), ) def __init__(self, *args, **kwargs): """Initialize multi select with choices weekdays.""" - kwargs['choices'] = self.WEEKDAYS + kwargs["choices"] = self.WEEKDAYS super().__init__(*args, **kwargs) diff --git a/timed/permissions.py b/timed/permissions.py index b3a3946b7..d9a4dbb38 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -1,5 +1,4 @@ -from rest_framework.permissions import (SAFE_METHODS, BasePermission, - IsAuthenticated) +from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated class IsUnverified(BasePermission): @@ -23,7 +22,7 @@ class IsDeleteOnly(BasePermission): """Allows only delete method.""" def has_permission(self, request, view): - return request.method == 'DELETE' + return request.method == "DELETE" def has_object_permission(self, request, view, obj): return self.has_permission(request, view) @@ -33,7 +32,7 @@ class IsCreateOnly(BasePermission): """Allows only create method.""" def has_permission(self, request, view): - return request.method == 'POST' + return request.method == "POST" def has_object_permission(self, request, view, obj): return self.has_permission(request, view) @@ -43,7 +42,7 @@ class IsUpdateOnly(BasePermission): """Allows only update method.""" def has_permission(self, request, view): - return request.method in ['PATCH', 'PUT'] + return request.method in ["PATCH", "PUT"] def has_object_permission(self, request, view, obj): return self.has_permission(request, view) diff --git a/timed/projects/__init__.py b/timed/projects/__init__.py index 6df94d2af..1f93e5943 100644 --- a/timed/projects/__init__.py +++ b/timed/projects/__init__.py @@ -1,3 +1,3 @@ # noqa: D104 -default_app_config = 'timed.projects.apps.ProjectsConfig' +default_app_config = "timed.projects.apps.ProjectsConfig" diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 714aa9b43..795ce83a3 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -15,11 +15,9 @@ class CustomerAdmin(admin.ModelAdmin): """Customer admin view.""" - list_display = ['name'] - search_fields = ['name'] - inlines = [ - CustomerPasswordInline - ] + list_display = ["name"] + search_fields = ["name"] + inlines = [CustomerPasswordInline] def has_delete_permission(self, request, obj=None): return obj and not obj.projects.exists() @@ -27,14 +25,14 @@ def has_delete_permission(self, request, obj=None): @admin.register(models.BillingType) class BillingType(admin.ModelAdmin): - list_display = ['name'] - search_fields = ['name'] + list_display = ["name"] + search_fields = ["name"] @admin.register(models.CostCenter) class CostCenter(admin.ModelAdmin): - list_display = ['name', 'reference'] - search_fields = ['name'] + list_display = ["name", "reference"] + search_fields = ["name"] class TaskForm(forms.ModelForm): @@ -46,15 +44,14 @@ class TaskForm(forms.ModelForm): model = models.Task estimated_time = DurationInHoursField( - label=_('Estimated time in hours'), - required=False, + label=_("Estimated time in hours"), required=False ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - initial = kwargs.get('initial') + initial = kwargs.get("initial") if initial: - self.changed_data = ['name'] + self.changed_data = ["name"] class TaskInlineFormset(BaseInlineFormSet): @@ -62,11 +59,11 @@ class TaskInlineFormset(BaseInlineFormSet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - project = kwargs['instance'] + project = kwargs["instance"] if project.tasks.count() == 0: self.initial = [ - {'name': tmpl.name} - for tmpl in models.TaskTemplate.objects.order_by('name') + {"name": tmpl.name} + for tmpl in models.TaskTemplate.objects.order_by("name") ] self.extra += len(self.initial) @@ -87,15 +84,14 @@ def has_delete_permission(self, request, obj=None): class ReviewerInline(admin.TabularInline): model = models.Project.reviewers.through extra = 0 - verbose_name = _('Reviewer') - verbose_name_plural = _('Reviewers') + verbose_name = _("Reviewer") + verbose_name_plural = _("Reviewers") class ProjectForm(forms.ModelForm): model = models.Project estimated_time = DurationInHoursField( - label=_('Estimated time in hours'), - required=False, + label=_("Estimated time in hours"), required=False ) @@ -104,16 +100,12 @@ class ProjectAdmin(admin.ModelAdmin): """Project admin view.""" form = ProjectForm - list_display = ['name', 'customer'] - list_filter = ['customer'] - search_fields = ['name', 'customer__name'] + list_display = ["name", "customer"] + list_filter = ["customer"] + search_fields = ["name", "customer__name"] - inlines = [ - TaskInline, - ReviewerInline, - RedmineProjectInline - ] - exclude = ('reviewers', ) + inlines = [TaskInline, ReviewerInline, RedmineProjectInline] + exclude = ("reviewers",) def has_delete_permission(self, request, obj=None): return obj and not obj.tasks.exists() @@ -123,4 +115,4 @@ def has_delete_permission(self, request, obj=None): class TaskTemplateAdmin(admin.ModelAdmin): """Task template admin view.""" - list_display = ['name'] + list_display = ["name"] diff --git a/timed/projects/apps.py b/timed/projects/apps.py index afa2f4307..7959e1769 100644 --- a/timed/projects/apps.py +++ b/timed/projects/apps.py @@ -6,5 +6,5 @@ class ProjectsConfig(AppConfig): """App configuration for projects app.""" - name = 'timed.projects' - label = 'projects' + name = "timed.projects" + label = "projects" diff --git a/timed/projects/factories.py b/timed/projects/factories.py index a88a4c9df..1c75884c8 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -9,10 +9,10 @@ class CustomerFactory(DjangoModelFactory): """Customer factory.""" - name = Faker('company') - email = Faker('company_email') - website = Faker('url') - comment = Faker('sentence') + name = Faker("company") + email = Faker("company_email") + website = Faker("url") + comment = Faker("sentence") archived = False class Meta: @@ -22,7 +22,7 @@ class Meta: class BillingTypeFactory(DjangoModelFactory): - name = Faker('currency_name') + name = Faker("currency_name") reference = None class Meta: @@ -30,7 +30,7 @@ class Meta: class CostCenterFactory(DjangoModelFactory): - name = Faker('job') + name = Faker("job") reference = None class Meta: @@ -40,13 +40,13 @@ class Meta: class ProjectFactory(DjangoModelFactory): """Project factory.""" - name = Faker('catch_phrase') - estimated_time = Faker('time_delta') - archived = False - comment = Faker('sentence') - customer = SubFactory('timed.projects.factories.CustomerFactory') - cost_center = SubFactory('timed.projects.factories.CostCenterFactory') - billing_type = SubFactory('timed.projects.factories.BillingTypeFactory') + name = Faker("catch_phrase") + estimated_time = Faker("time_delta") + archived = False + comment = Faker("sentence") + customer = SubFactory("timed.projects.factories.CustomerFactory") + cost_center = SubFactory("timed.projects.factories.CostCenterFactory") + billing_type = SubFactory("timed.projects.factories.BillingTypeFactory") class Meta: """Meta informations for the project factory.""" @@ -57,11 +57,11 @@ class Meta: class TaskFactory(DjangoModelFactory): """Task factory.""" - name = Faker('company_suffix') - estimated_time = Faker('time_delta') - archived = False - project = SubFactory('timed.projects.factories.ProjectFactory') - cost_center = SubFactory('timed.projects.factories.CostCenterFactory') + name = Faker("company_suffix") + estimated_time = Faker("time_delta") + archived = False + project = SubFactory("timed.projects.factories.ProjectFactory") + cost_center = SubFactory("timed.projects.factories.CostCenterFactory") class Meta: """Meta informations for the task factory.""" @@ -72,7 +72,7 @@ class Meta: class TaskTemplateFactory(DjangoModelFactory): """Task template factory.""" - name = Faker('sentence') + name = Faker("sentence") class Meta: """Meta informations for the task template factory.""" diff --git a/timed/projects/filters.py b/timed/projects/filters.py index a0725f51d..58551e0c6 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -11,35 +11,26 @@ class CustomerFilterSet(FilterSet): """Filter set for the customers endpoint.""" - archived = NumberFilter(field_name='archived') + archived = NumberFilter(field_name="archived") class Meta: """Meta information for the customer filter set.""" - model = models.Customer - fields = [ - 'archived', - 'reference' - ] + model = models.Customer + fields = ["archived", "reference"] class ProjectFilterSet(FilterSet): """Filter set for the projects endpoint.""" - archived = NumberFilter(field_name='archived') - reviewer = NumberFilter(field_name='reviewers') + archived = NumberFilter(field_name="archived") + reviewer = NumberFilter(field_name="reviewers") class Meta: """Meta information for the project filter set.""" - model = models.Project - fields = [ - 'archived', - 'customer', - 'billing_type', - 'cost_center', - 'reference' - ] + model = models.Project + fields = ["archived", "customer", "billing_type", "cost_center", "reference"] class MyMostFrequentTaskFilter(Filter): @@ -73,11 +64,11 @@ def filter(self, qs, value): reports__user=user, reports__date__gt=from_date, archived=False, - project__archived=False + project__archived=False, ) - qs = qs.annotate(frequency=Count('reports')).order_by('-frequency') + qs = qs.annotate(frequency=Count("reports")).order_by("-frequency") # limit number of results to given value - qs = qs[:int(value)] + qs = qs[: int(value)] return qs @@ -86,16 +77,10 @@ class TaskFilterSet(FilterSet): """Filter set for the tasks endpoint.""" my_most_frequent = MyMostFrequentTaskFilter() - archived = NumberFilter(field_name='archived') + archived = NumberFilter(field_name="archived") class Meta: """Meta information for the task filter set.""" - model = models.Task - fields = [ - 'archived', - 'project', - 'my_most_frequent', - 'reference', - 'cost_center' - ] + model = models.Task + fields = ["archived", "project", "my_most_frequent", "reference", "cost_center"] diff --git a/timed/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py index 910c06335..d0ec34d15 100644 --- a/timed/projects/migrations/0001_initial.py +++ b/timed/projects/migrations/0001_initial.py @@ -11,69 +11,141 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( - name='BillingType', + name="BillingType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), ], ), migrations.CreateModel( - name='Customer', + name="Customer", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('email', models.EmailField(blank=True, max_length=254)), - ('website', models.URLField(blank=True)), - ('comment', models.TextField(blank=True)), - ('archived', models.BooleanField(default=False)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("email", models.EmailField(blank=True, max_length=254)), + ("website", models.URLField(blank=True)), + ("comment", models.TextField(blank=True)), + ("archived", models.BooleanField(default=False)), ], ), migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('comment', models.TextField(blank=True)), - ('archived', models.BooleanField(default=False)), - ('estimated_hours', models.PositiveIntegerField(blank=True, null=True)), - ('billing_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.BillingType')), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.Customer')), - ('reviewers', models.ManyToManyField(related_name='reviews', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("comment", models.TextField(blank=True)), + ("archived", models.BooleanField(default=False)), + ("estimated_hours", models.PositiveIntegerField(blank=True, null=True)), + ( + "billing_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="projects", + to="projects.BillingType", + ), + ), + ( + "customer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="projects", + to="projects.Customer", + ), + ), + ( + "reviewers", + models.ManyToManyField( + related_name="reviews", to=settings.AUTH_USER_MODEL + ), + ), ], ), migrations.CreateModel( - name='Task', + name="Task", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('estimated_hours', models.PositiveIntegerField(blank=True, null=True)), - ('archived', models.BooleanField(default=False)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.Project')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("estimated_hours", models.PositiveIntegerField(blank=True, null=True)), + ("archived", models.BooleanField(default=False)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tasks", + to="projects.Project", + ), + ), ], ), migrations.CreateModel( - name='TaskTemplate', + name="TaskTemplate", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), ], ), migrations.AddIndex( - model_name='customer', - index=models.Index(fields=['name', 'archived'], name='projects_cu_name_e0e97a_idx'), + model_name="customer", + index=models.Index( + fields=["name", "archived"], name="projects_cu_name_e0e97a_idx" + ), ), migrations.AddIndex( - model_name='task', - index=models.Index(fields=['name', 'archived'], name='projects_ta_name_dd9620_idx'), + model_name="task", + index=models.Index( + fields=["name", "archived"], name="projects_ta_name_dd9620_idx" + ), ), migrations.AddIndex( - model_name='project', - index=models.Index(fields=['name', 'archived'], name='projects_pr_name_ac60a8_idx'), + model_name="project", + index=models.Index( + fields=["name", "archived"], name="projects_pr_name_ac60a8_idx" + ), ), ] diff --git a/timed/projects/migrations/0002_auto_20170823_1045.py b/timed/projects/migrations/0002_auto_20170823_1045.py index 20b212a1e..6aff4650e 100644 --- a/timed/projects/migrations/0002_auto_20170823_1045.py +++ b/timed/projects/migrations/0002_auto_20170823_1045.py @@ -8,14 +8,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('projects', '0001_initial'), - ] + dependencies = [("projects", "0001_initial")] operations = [ migrations.AlterField( - model_name='project', - name='billing_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='projects.BillingType'), - ), + model_name="project", + name="billing_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projects", + to="projects.BillingType", + ), + ) ] diff --git a/timed/projects/migrations/0003_auto_20170831_1624.py b/timed/projects/migrations/0003_auto_20170831_1624.py index a3bf4b77e..ad7c8ec4a 100644 --- a/timed/projects/migrations/0003_auto_20170831_1624.py +++ b/timed/projects/migrations/0003_auto_20170831_1624.py @@ -7,25 +7,13 @@ class Migration(migrations.Migration): - dependencies = [ - ('projects', '0002_auto_20170823_1045'), - ] + dependencies = [("projects", "0002_auto_20170823_1045")] operations = [ + migrations.AlterModelOptions(name="customer", options={"ordering": ["name"]}), + migrations.AlterModelOptions(name="project", options={"ordering": ["name"]}), + migrations.AlterModelOptions(name="task", options={"ordering": ["name"]}), migrations.AlterModelOptions( - name='customer', - options={'ordering': ['name']}, - ), - migrations.AlterModelOptions( - name='project', - options={'ordering': ['name']}, - ), - migrations.AlterModelOptions( - name='task', - options={'ordering': ['name']}, - ), - migrations.AlterModelOptions( - name='tasktemplate', - options={'ordering': ['name']}, + name="tasktemplate", options={"ordering": ["name"]} ), ] diff --git a/timed/projects/migrations/0004_auto_20170906_1045.py b/timed/projects/migrations/0004_auto_20170906_1045.py index a0b8cead9..d9580d10a 100644 --- a/timed/projects/migrations/0004_auto_20170906_1045.py +++ b/timed/projects/migrations/0004_auto_20170906_1045.py @@ -8,13 +8,13 @@ def migrate_estimated_hours(apps, schema_editor): - Project = apps.get_model('projects', 'Project') + Project = apps.get_model("projects", "Project") projects = Project.objects.filter(estimated_hours__isnull=False) for project in projects: project.estimated_time = timedelta(hours=project.estimated_hours) project.save() - Task = apps.get_model('projects', 'Task') + Task = apps.get_model("projects", "Task") tasks = Task.objects.filter(estimated_hours__isnull=False) for task in tasks: task.estimated_time = timedelta(hours=task.estimated_hours) @@ -23,28 +23,20 @@ def migrate_estimated_hours(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('projects', '0003_auto_20170831_1624'), - ] + dependencies = [("projects", "0003_auto_20170831_1624")] operations = [ migrations.AddField( - model_name='project', - name='estimated_time', + model_name="project", + name="estimated_time", field=models.DurationField(blank=True, null=True), ), migrations.AddField( - model_name='task', - name='estimated_time', + model_name="task", + name="estimated_time", field=models.DurationField(blank=True, null=True), ), migrations.RunPython(migrate_estimated_hours), - migrations.RemoveField( - model_name='project', - name='estimated_hours', - ), - migrations.RemoveField( - model_name='task', - name='estimated_hours', - ), + migrations.RemoveField(model_name="project", name="estimated_hours"), + migrations.RemoveField(model_name="task", name="estimated_hours"), ] diff --git a/timed/projects/migrations/0005_auto_20170907_0938.py b/timed/projects/migrations/0005_auto_20170907_0938.py index 9395061fd..a54983368 100644 --- a/timed/projects/migrations/0005_auto_20170907_0938.py +++ b/timed/projects/migrations/0005_auto_20170907_0938.py @@ -7,55 +7,53 @@ class Migration(migrations.Migration): - dependencies = [ - ('projects', '0004_auto_20170906_1045'), - ] + dependencies = [("projects", "0004_auto_20170906_1045")] operations = [ migrations.AlterModelOptions( - name='billingtype', - options={'ordering': ['name']}, - ), - migrations.RemoveIndex( - model_name='customer', - name='projects_cu_name_e0e97a_idx', + name="billingtype", options={"ordering": ["name"]} ), migrations.RemoveIndex( - model_name='task', - name='projects_ta_name_dd9620_idx', + model_name="customer", name="projects_cu_name_e0e97a_idx" ), + migrations.RemoveIndex(model_name="task", name="projects_ta_name_dd9620_idx"), migrations.RemoveIndex( - model_name='project', - name='projects_pr_name_ac60a8_idx', + model_name="project", name="projects_pr_name_ac60a8_idx" ), migrations.AddField( - model_name='billingtype', - name='reference', + model_name="billingtype", + name="reference", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='customer', - name='reference', - field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + model_name="customer", + name="reference", + field=models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), ), migrations.AddField( - model_name='project', - name='reference', - field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + model_name="project", + name="reference", + field=models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), ), migrations.AddField( - model_name='task', - name='reference', - field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + model_name="task", + name="reference", + field=models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), ), migrations.AlterField( - model_name='customer', - name='name', + model_name="customer", + name="name", field=models.CharField(max_length=255, unique=True), ), migrations.AlterField( - model_name='project', - name='name', + model_name="project", + name="name", field=models.CharField(db_index=True, max_length=255), ), ] diff --git a/timed/projects/migrations/0006_auto_20171010_1423.py b/timed/projects/migrations/0006_auto_20171010_1423.py index 689e2a506..4ee5adb8d 100644 --- a/timed/projects/migrations/0006_auto_20171010_1423.py +++ b/timed/projects/migrations/0006_auto_20171010_1423.py @@ -8,30 +8,46 @@ class Migration(migrations.Migration): - dependencies = [ - ('projects', '0005_auto_20170907_0938'), - ] + dependencies = [("projects", "0005_auto_20170907_0938")] operations = [ migrations.CreateModel( - name='CostCenter', + name="CostCenter", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('reference', models.CharField(blank=True, max_length=255, null=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("reference", models.CharField(blank=True, max_length=255, null=True)), ], - options={ - 'ordering': ['name'], - }, + options={"ordering": ["name"]}, ), migrations.AddField( - model_name='project', - name='cost_center', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='projects.CostCenter'), + model_name="project", + name="cost_center", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projects", + to="projects.CostCenter", + ), ), migrations.AddField( - model_name='task', - name='cost_center', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='projects.CostCenter'), + model_name="task", + name="cost_center", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tasks", + to="projects.CostCenter", + ), ), ] diff --git a/timed/projects/migrations/0007_project_subscription_project.py b/timed/projects/migrations/0007_project_subscription_project.py index def453bf5..d58ecb267 100644 --- a/timed/projects/migrations/0007_project_subscription_project.py +++ b/timed/projects/migrations/0007_project_subscription_project.py @@ -5,26 +5,27 @@ from django.db import migrations, models + def migrate_projects(apps, schema_editor): """Set subsctition_project on Projects with orders.""" - Project = apps.get_model('projects', 'Project') - visible_projects = Project.objects.annotate( - count_orders=Count('orders') - ).filter(archived=False, count_orders__gt=0) + Project = apps.get_model("projects", "Project") + visible_projects = Project.objects.annotate(count_orders=Count("orders")).filter( + archived=False, count_orders__gt=0 + ) visible_projects.update(customer_visible=True) class Migration(migrations.Migration): dependencies = [ - ('projects', '0006_auto_20171010_1423'), - ('subscription', '0003_auto_20170907_1151'), + ("projects", "0006_auto_20171010_1423"), + ("subscription", "0003_auto_20170907_1151"), ] operations = [ migrations.AddField( - model_name='project', - name='customer_visible', + model_name="project", + name="customer_visible", field=models.BooleanField(default=False), ), migrations.RunPython(migrate_projects), diff --git a/timed/projects/models.py b/timed/projects/models.py index 914a64bb6..42a7ae221 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -11,13 +11,12 @@ class Customer(models.Model): reported on their projects. """ - name = models.CharField(max_length=255, unique=True) - reference = models.CharField(max_length=255, db_index=True, - blank=True, null=True) - email = models.EmailField(blank=True) - website = models.URLField(blank=True) - comment = models.TextField(blank=True) - archived = models.BooleanField(default=False) + name = models.CharField(max_length=255, unique=True) + reference = models.CharField(max_length=255, db_index=True, blank=True, null=True) + email = models.EmailField(blank=True) + website = models.URLField(blank=True) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) def __str__(self): """Represent the model as a string. @@ -30,33 +29,33 @@ def __str__(self): class Meta: """Meta informations for the customer model.""" - ordering = ['name'] + ordering = ["name"] class CostCenter(models.Model): """Cost center defining how cost of projects and tasks are allocated.""" - name = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255, unique=True) reference = models.CharField(max_length=255, blank=True, null=True) def __str__(self): return self.name class Meta: - ordering = ['name'] + ordering = ["name"] class BillingType(models.Model): """Billing type defining how a project, resp. reports are being billed.""" - name = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255, unique=True) reference = models.CharField(max_length=255, blank=True, null=True) def __str__(self): return self.name class Meta: - ordering = ['name'] + ordering = ["name"] class Project(models.Model): @@ -66,26 +65,30 @@ class Project(models.Model): belongs to a customer. """ - name = models.CharField(max_length=255, db_index=True) - reference = models.CharField(max_length=255, db_index=True, - blank=True, null=True) - comment = models.TextField(blank=True) - archived = models.BooleanField(default=False) - estimated_time = models.DurationField(blank=True, null=True) - customer = models.ForeignKey('projects.Customer', - on_delete=models.CASCADE, - related_name='projects') - billing_type = models.ForeignKey(BillingType, - on_delete=models.SET_NULL, - blank=True, null=True, - related_name='projects') - cost_center = models.ForeignKey(CostCenter, - on_delete=models.SET_NULL, - blank=True, null=True, - related_name='projects') - reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, - related_name='reviews') - customer_visible = models.BooleanField(default=False) + name = models.CharField(max_length=255, db_index=True) + reference = models.CharField(max_length=255, db_index=True, blank=True, null=True) + comment = models.TextField(blank=True) + archived = models.BooleanField(default=False) + estimated_time = models.DurationField(blank=True, null=True) + customer = models.ForeignKey( + "projects.Customer", on_delete=models.CASCADE, related_name="projects" + ) + billing_type = models.ForeignKey( + BillingType, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="projects", + ) + cost_center = models.ForeignKey( + CostCenter, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="projects", + ) + reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="reviews") + customer_visible = models.BooleanField(default=False) def __str__(self): """Represent the model as a string. @@ -93,10 +96,10 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{0} > {1}'.format(self.customer, self.name) + return "{0} > {1}".format(self.customer, self.name) class Meta: - ordering = ['name'] + ordering = ["name"] class Task(models.Model): @@ -106,17 +109,20 @@ class Task(models.Model): report their activities and reports on it. """ - name = models.CharField(max_length=255) - reference = models.CharField(max_length=255, db_index=True, - blank=True, null=True) - estimated_time = models.DurationField(blank=True, null=True) - archived = models.BooleanField(default=False) - project = models.ForeignKey('projects.Project', - on_delete=models.CASCADE, - related_name='tasks') - cost_center = models.ForeignKey(CostCenter, on_delete=models.SET_NULL, - blank=True, null=True, - related_name='tasks') + name = models.CharField(max_length=255) + reference = models.CharField(max_length=255, db_index=True, blank=True, null=True) + estimated_time = models.DurationField(blank=True, null=True) + archived = models.BooleanField(default=False) + project = models.ForeignKey( + "projects.Project", on_delete=models.CASCADE, related_name="tasks" + ) + cost_center = models.ForeignKey( + CostCenter, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="tasks", + ) def __str__(self): """Represent the model as a string. @@ -124,12 +130,12 @@ def __str__(self): :return: The string representation :rtype: str """ - return '{0} > {1}'.format(self.project, self.name) + return "{0} > {1}".format(self.project, self.name) class Meta: """Meta informations for the task model.""" - ordering = ['name'] + ordering = ["name"] class TaskTemplate(models.Model): @@ -150,4 +156,4 @@ def __str__(self): return self.name class Meta: - ordering = ['name'] + ordering = ["name"] diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index db6e96415..07222dccd 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -16,57 +16,40 @@ class CustomerSerializer(ModelSerializer): class Meta: """Meta information for the customer serializer.""" - model = models.Customer - fields = [ - 'name', - 'reference', - 'email', - 'website', - 'comment', - 'archived', - ] + model = models.Customer + fields = ["name", "reference", "email", "website", "comment", "archived"] class BillingTypeSerializer(ModelSerializer): class Meta: - model = models.BillingType - fields = [ - 'name', - 'reference' - ] + model = models.BillingType + fields = ["name", "reference"] class CostCenterSerializer(ModelSerializer): class Meta: - model = models.CostCenter - fields = [ - 'name', - 'reference' - ] + model = models.CostCenter + fields = ["name", "reference"] class ProjectSerializer(ModelSerializer): """Project serializer.""" customer = ResourceRelatedField(queryset=models.Customer.objects.all()) - billing_type = ResourceRelatedField( - queryset=models.BillingType.objects.all() - ) + billing_type = ResourceRelatedField(queryset=models.BillingType.objects.all()) included_serializers = { - 'customer': 'timed.projects.serializers.CustomerSerializer', - 'billing_type': 'timed.projects.serializers.BillingTypeSerializer', - 'cost_center': 'timed.projects.serializers.CostCenterSerializer', - 'reviewers': 'timed.employment.serializers.UserSerializer', + "customer": "timed.projects.serializers.CustomerSerializer", + "billing_type": "timed.projects.serializers.BillingTypeSerializer", + "cost_center": "timed.projects.serializers.CostCenterSerializer", + "reviewers": "timed.employment.serializers.UserSerializer", } def get_root_meta(self, resource, many): if not many: queryset = Report.objects.filter(task__project=self.instance) - data = queryset.aggregate(spent_time=Sum('duration')) - data['spent_time'] = duration_string( - data['spent_time'] or timedelta(0) - ) + data = queryset.aggregate(spent_time=Sum("duration")) + data["spent_time"] = duration_string(data["spent_time"] or timedelta(0)) return data return {} @@ -74,17 +57,17 @@ def get_root_meta(self, resource, many): class Meta: """Meta information for the project serializer.""" - model = models.Project + model = models.Project fields = [ - 'name', - 'reference', - 'comment', - 'estimated_time', - 'archived', - 'customer', - 'billing_type', - 'cost_center', - 'reviewers' + "name", + "reference", + "comment", + "estimated_time", + "archived", + "customer", + "billing_type", + "cost_center", + "reviewers", ] @@ -94,18 +77,16 @@ class TaskSerializer(ModelSerializer): project = ResourceRelatedField(queryset=models.Project.objects.all()) included_serializers = { - 'activities': 'timed.tracking.serializers.ActivitySerializer', - 'project': 'timed.projects.serializers.ProjectSerializer', - 'cost_center': 'timed.projects.serializers.CostCenterSerializer' + "activities": "timed.tracking.serializers.ActivitySerializer", + "project": "timed.projects.serializers.ProjectSerializer", + "cost_center": "timed.projects.serializers.CostCenterSerializer", } def get_root_meta(self, resource, many): if not many: queryset = Report.objects.filter(task=self.instance) - data = queryset.aggregate(spent_time=Sum('duration')) - data['spent_time'] = duration_string( - data['spent_time'] or timedelta(0) - ) + data = queryset.aggregate(spent_time=Sum("duration")) + data["spent_time"] = duration_string(data["spent_time"] or timedelta(0)) return data return {} @@ -113,12 +94,12 @@ def get_root_meta(self, resource, many): class Meta: """Meta information for the task serializer.""" - model = models.Task + model = models.Task fields = [ - 'name', - 'reference', - 'estimated_time', - 'archived', - 'project', - 'cost_center' + "name", + "reference", + "estimated_time", + "archived", + "project", + "cost_center", ] diff --git a/timed/projects/tests/test_billing_type.py b/timed/projects/tests/test_billing_type.py index b7d1ed5c0..ba3bb43c6 100644 --- a/timed/projects/tests/test_billing_type.py +++ b/timed/projects/tests/test_billing_type.py @@ -6,10 +6,10 @@ def test_billing_type_list(auth_client): billing_type = BillingTypeFactory.create() - url = reverse('billing-type-list') + url = reverse("billing-type-list") res = auth_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(billing_type.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(billing_type.id) diff --git a/timed/projects/tests/test_cost_center.py b/timed/projects/tests/test_cost_center.py index 0dbf07f75..26b0e9a01 100644 --- a/timed/projects/tests/test_cost_center.py +++ b/timed/projects/tests/test_cost_center.py @@ -6,10 +6,10 @@ def test_cost_center_list(auth_client): cost_center = CostCenterFactory.create() - url = reverse('cost-center-list') + url = reverse("cost-center-list") res = auth_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(cost_center.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(cost_center.id) diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 5d0de9d63..4113eec2e 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -10,29 +10,27 @@ def test_customer_list_not_archived(auth_client): CustomerFactory.create(archived=True) customer = CustomerFactory.create(archived=False) - url = reverse('customer-list') + url = reverse("customer-list") - response = auth_client.get(url, data={'archived': 0}) + response = auth_client.get(url, data={"archived": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(customer.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(customer.id) def test_customer_detail(auth_client): customer = CustomerFactory.create() - url = reverse('customer-detail', args=[ - customer.id - ]) + url = reverse("customer-detail", args=[customer.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK def test_customer_create(auth_client): - url = reverse('customer-list') + url = reverse("customer-list") response = auth_client.post(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -41,9 +39,7 @@ def test_customer_create(auth_client): def test_customer_update(auth_client): customer = CustomerFactory.create() - url = reverse('customer-detail', args=[ - customer.id - ]) + url = reverse("customer-detail", args=[customer.id]) response = auth_client.patch(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -52,9 +48,7 @@ def test_customer_update(auth_client): def test_customer_delete(auth_client): customer = CustomerFactory.create() - url = reverse('customer-detail', args=[ - customer.id - ]) + url = reverse("customer-detail", args=[customer.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 079d1da7a..0b18b0c85 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -14,14 +14,14 @@ def test_project_list_not_archived(auth_client): project = ProjectFactory.create(archived=False) ProjectFactory.create(archived=True) - url = reverse('project-list') + url = reverse("project-list") - response = auth_client.get(url, data={'archived': 0}) + response = auth_client.get(url, data={"archived": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(project.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(project.id) def test_project_list_include(auth_client, django_assert_num_queries): @@ -29,25 +29,24 @@ def test_project_list_include(auth_client, django_assert_num_queries): users = UserFactory.create_batch(2) project.reviewers.add(*users) - url = reverse('project-list') + url = reverse("project-list") with django_assert_num_queries(6): - response = auth_client.get(url, data={ - 'include': ','.join(ProjectSerializer.included_serializers.keys()) - }) + response = auth_client.get( + url, + data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, + ) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(project.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(project.id) def test_project_detail_no_auth(client): project = ProjectFactory.create() - url = reverse('project-detail', args=[ - project.id - ]) + url = reverse("project-detail", args=[project.id]) res = client.get(url) assert res.status_code == status.HTTP_401_UNAUTHORIZED @@ -56,16 +55,14 @@ def test_project_detail_no_auth(client): def test_project_detail_no_reports(auth_client): project = ProjectFactory.create() - url = reverse('project-detail', args=[ - project.id - ]) + url = reverse("project-detail", args=[project.id]) res = auth_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() - assert json['meta']['spent-time'] == '00:00:00' + assert json["meta"]["spent-time"] == "00:00:00" def test_project_detail_with_reports(auth_client): @@ -73,20 +70,18 @@ def test_project_detail_with_reports(auth_client): task = TaskFactory.create(project=project) ReportFactory.create_batch(10, task=task, duration=timedelta(hours=1)) - url = reverse('project-detail', args=[ - project.id - ]) + url = reverse("project-detail", args=[project.id]) res = auth_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() - assert json['meta']['spent-time'] == '10:00:00' + assert json["meta"]["spent-time"] == "10:00:00" def test_project_create(auth_client): - url = reverse('project-list') + url = reverse("project-list") response = auth_client.post(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -95,9 +90,7 @@ def test_project_create(auth_client): def test_project_update(auth_client): project = ProjectFactory.create() - url = reverse('project-detail', args=[ - project.id - ]) + url = reverse("project-detail", args=[project.id]) response = auth_client.patch(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -106,9 +99,7 @@ def test_project_update(auth_client): def test_project_delete(auth_client): project = ProjectFactory.create() - url = reverse('project-detail', args=[ - project.id - ]) + url = reverse("project-detail", args=[project.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 99af34788..f7bcd6669 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -11,14 +11,14 @@ def test_task_list_not_archived(auth_client): task = TaskFactory.create(archived=False) TaskFactory.create(archived=True) - url = reverse('task-list') + url = reverse("task-list") - response = auth_client.get(url, data={'archived': 0}) + response = auth_client.get(url, data={"archived": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(task.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(task.id) def test_task_my_most_frequent(auth_client): @@ -29,54 +29,42 @@ def test_task_my_most_frequent(auth_client): old_report_date = date.today() - timedelta(days=90) # tasks[0] should appear as most frequently used task - ReportFactory.create_batch( - 5, date=report_date, user=user, task=tasks[0] - ) + ReportFactory.create_batch(5, date=report_date, user=user, task=tasks[0]) # tasks[1] should appear as secondly most frequently used task - ReportFactory.create_batch( - 4, date=report_date, user=user, task=tasks[1] - ) + ReportFactory.create_batch(4, date=report_date, user=user, task=tasks[1]) # tasks[2] should not appear in result, as too far in the past - ReportFactory.create_batch( - 4, date=old_report_date, user=user, task=tasks[2] - ) + ReportFactory.create_batch(4, date=old_report_date, user=user, task=tasks[2]) # tasks[3] should not appear in result, as project is archived tasks[3].project.archived = True tasks[3].project.save() - ReportFactory.create_batch( - 4, date=report_date, user=user, task=tasks[3] - ) + ReportFactory.create_batch(4, date=report_date, user=user, task=tasks[3]) # tasks[4] should not appear in result, as task is archived tasks[4].archived = True tasks[4].save() - ReportFactory.create_batch( - 4, date=report_date, user=user, task=tasks[4] - ) + ReportFactory.create_batch(4, date=report_date, user=user, task=tasks[4]) - url = reverse('task-list') + url = reverse("task-list") - response = auth_client.get(url, {'my_most_frequent': '10'}) + response = auth_client.get(url, {"my_most_frequent": "10"}) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] + data = response.json()["data"] assert len(data) == 2 - assert data[0]['id'] == str(tasks[0].id) - assert data[1]['id'] == str(tasks[1].id) + assert data[0]["id"] == str(tasks[0].id) + assert data[1]["id"] == str(tasks[1].id) def test_task_detail(auth_client): task = TaskFactory.create() - url = reverse('task-detail', args=[ - task.id - ]) + url = reverse("task-detail", args=[task.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK def test_task_create(auth_client): - url = reverse('task-list') + url = reverse("task-list") response = auth_client.post(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -85,9 +73,7 @@ def test_task_create(auth_client): def test_task_update(auth_client): task = TaskFactory.create() - url = reverse('task-detail', args=[ - task.id - ]) + url = reverse("task-detail", args=[task.id]) response = auth_client.patch(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -96,9 +82,7 @@ def test_task_update(auth_client): def test_task_delete(auth_client): task = TaskFactory.create() - url = reverse('task-detail', args=[ - task.id - ]) + url = reverse("task-detail", args=[task.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -107,29 +91,25 @@ def test_task_delete(auth_client): def test_task_detail_no_reports(auth_client): task = TaskFactory.create() - url = reverse('task-detail', args=[ - task.id - ]) + url = reverse("task-detail", args=[task.id]) res = auth_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() - assert json['meta']['spent-time'] == '00:00:00' + assert json["meta"]["spent-time"] == "00:00:00" def test_task_detail_with_reports(auth_client): task = TaskFactory.create() ReportFactory.create_batch(5, task=task, duration=timedelta(minutes=30)) - url = reverse('task-detail', args=[ - task.id - ]) + url = reverse("task-detail", args=[task.id]) res = auth_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() - assert json['meta']['spent-time'] == '02:30:00' + assert json["meta"]["spent-time"] == "02:30:00" diff --git a/timed/projects/urls.py b/timed/projects/urls.py index 50eeef3ce..2ffc857cb 100644 --- a/timed/projects/urls.py +++ b/timed/projects/urls.py @@ -7,10 +7,10 @@ r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'projects', views.ProjectViewSet, 'project') -r.register(r'customers', views.CustomerViewSet, 'customer') -r.register(r'tasks', views.TaskViewSet, 'task') -r.register(r'billing-types', views.BillingTypeViewSet, 'billing-type') -r.register(r'cost-centers', views.CostCenterViewSet, 'cost-center') +r.register(r"projects", views.ProjectViewSet, "project") +r.register(r"customers", views.CustomerViewSet, "customer") +r.register(r"tasks", views.TaskViewSet, "task") +r.register(r"billing-types", views.BillingTypeViewSet, "billing-type") +r.register(r"cost-centers", views.CostCenterViewSet, "cost-center") urlpatterns = r.urls diff --git a/timed/projects/views.py b/timed/projects/views.py index d990e8a03..9eb1d9b98 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -10,8 +10,8 @@ class CustomerViewSet(ReadOnlyModelViewSet): """Customer view set.""" serializer_class = serializers.CustomerSerializer - filterset_class = filters.CustomerFilterSet - ordering = 'name' + filterset_class = filters.CustomerFilterSet + ordering = "name" def get_queryset(self): """Prefetch related data. @@ -19,14 +19,12 @@ def get_queryset(self): :return: The customers :rtype: QuerySet """ - return models.Customer.objects.prefetch_related( - 'projects' - ) + return models.Customer.objects.prefetch_related("projects") class BillingTypeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.BillingTypeSerializer - ordering = 'name' + ordering = "name" def get_queryset(self): return models.BillingType.objects.all() @@ -34,7 +32,7 @@ def get_queryset(self): class CostCenterViewSet(ReadOnlyModelViewSet): serializer_class = serializers.CostCenterSerializer - ordering = 'name' + ordering = "name" def get_queryset(self): return models.CostCenter.objects.all() @@ -44,29 +42,27 @@ class ProjectViewSet(PrefetchForIncludesHelperMixin, ReadOnlyModelViewSet): """Project view set.""" serializer_class = serializers.ProjectSerializer - filterset_class = filters.ProjectFilterSet - ordering_fields = ('customer__name', 'name',) - ordering = 'name' - queryset = models.Project.objects.all() + filterset_class = filters.ProjectFilterSet + ordering_fields = ("customer__name", "name") + ordering = "name" + queryset = models.Project.objects.all() prefetch_for_includes = { - '__all__': ['reviewers'], - 'reviewers': ['reviewers__supervisors'], + "__all__": ["reviewers"], + "reviewers": ["reviewers__supervisors"], } def get_queryset(self): queryset = super().get_queryset() - return queryset.select_related( - 'customer', 'billing_type', 'cost_center' - ) + return queryset.select_related("customer", "billing_type", "cost_center") class TaskViewSet(ReadOnlyModelViewSet): """Task view set.""" serializer_class = serializers.TaskSerializer - filterset_class = filters.TaskFilterSet - ordering = 'name' + filterset_class = filters.TaskFilterSet + ordering = "name" def get_queryset(self): """Prefetch related data. @@ -74,16 +70,14 @@ def get_queryset(self): :return: The tasks :rtype: QuerySet """ - return models.Task.objects.select_related( - 'project', 'cost_center' - ) + return models.Task.objects.select_related("project", "cost_center") def filter_queryset(self, queryset): """Specific filter queryset options.""" # my most frequent filter uses LIMIT so default ordering # needs to be disabled to avoid exception # see TODO filters.MyMostFrequentTaskFilter to avoid this - if 'my_most_frequent' in self.request.query_params: + if "my_most_frequent" in self.request.query_params: self.ordering = None return super().filter_queryset(queryset) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 3bf8e2648..3ec199800 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -13,15 +13,15 @@ class Command(BaseCommand): - help = 'Update associated Redmine projects and send reports to watchers.' + help = "Update associated Redmine projects and send reports to watchers." def add_arguments(self, parser): parser.add_argument( - '--last-days', - dest='last_days', + "--last-days", + dest="last_days", default=7, - help='Build report of number of last days', - type=int + help="Build report of number of last days", + type=int, ) def handle(self, *args, **options): @@ -29,61 +29,66 @@ def handle(self, *args, **options): settings.REDMINE_URL, key=settings.REDMINE_APIKEY, requests={ - 'auth': ( + "auth": ( settings.REDMINE_HTACCESS_USER, - settings.REDMINE_HTACCESS_PASSWORD + settings.REDMINE_HTACCESS_PASSWORD, ) - } + }, ) - last_days = options['last_days'] + last_days = options["last_days"] # today is excluded end = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) start = end - timedelta(days=last_days) # get projects with reports in given last days - affected_projects = Project.objects.filter( - archived=False, - redmine_project__isnull=False, - tasks__reports__updated__range=[start, end] - ).annotate( - count_reports=Count('tasks__reports'), - ).filter(count_reports__gt=0).values('id') + affected_projects = ( + Project.objects.filter( + archived=False, + redmine_project__isnull=False, + tasks__reports__updated__range=[start, end], + ) + .annotate(count_reports=Count("tasks__reports")) + .filter(count_reports__gt=0) + .values("id") + ) # calculate total hours - projects = Project.objects.filter( - id__in=affected_projects - ).annotate(total_hours=Sum('tasks__reports__duration')) + projects = Project.objects.filter(id__in=affected_projects).annotate( + total_hours=Sum("tasks__reports__duration") + ) for project in projects: estimated_hours = ( - project.estimated_time and - project.estimated_time.total_seconds() / 3600 + project.estimated_time and project.estimated_time.total_seconds() / 3600 ) total_hours = project.total_hours.total_seconds() / 3600 try: issue = redmine.issue.get(project.redmine_project.issue_id) reports = Report.objects.filter( task__project=project, updated__range=[start, end] - ).order_by('date') - hours = reports.aggregate(hours=Sum('duration'))['hours'] + ).order_by("date") + hours = reports.aggregate(hours=Sum("duration"))["hours"] - issue.notes = render_to_string('redmine/weekly_report.txt', { - 'project': project, - 'hours': hours.total_seconds() / 3600, - 'last_days': last_days, - 'total_hours': total_hours, - 'estimated_hours': estimated_hours, - 'reports': reports - }, using='text') - issue.custom_fields = [{ - 'id': settings.REDMINE_SPENTHOURS_FIELD, - 'value': total_hours - }] + issue.notes = render_to_string( + "redmine/weekly_report.txt", + { + "project": project, + "hours": hours.total_seconds() / 3600, + "last_days": last_days, + "total_hours": total_hours, + "estimated_hours": estimated_hours, + "reports": reports, + }, + using="text", + ) + issue.custom_fields = [ + {"id": settings.REDMINE_SPENTHOURS_FIELD, "value": total_hours} + ] issue.save() except redminelib.exceptions.BaseRedmineError: sys.stderr.write( - 'Project {0} has an invalid Redmine ' - 'issue {1} assigned. Skipping'.format( + "Project {0} has an invalid Redmine " + "issue {1} assigned. Skipping".format( project.name, project.redmine_project.issue_id ) ) diff --git a/timed/redmine/migrations/0001_initial.py b/timed/redmine/migrations/0001_initial.py index cbb7aa0ac..b02e59b62 100644 --- a/timed/redmine/migrations/0001_initial.py +++ b/timed/redmine/migrations/0001_initial.py @@ -10,17 +10,30 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ('projects', '0001_initial'), - ] + dependencies = [("projects", "0001_initial")] operations = [ migrations.CreateModel( - name='RedmineProject', + name="RedmineProject", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('issue_id', models.PositiveIntegerField()), - ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='redmine_project', to='projects.Project')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("issue_id", models.PositiveIntegerField()), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="redmine_project", + to="projects.Project", + ), + ), ], - ), + ) ] diff --git a/timed/redmine/models.py b/timed/redmine/models.py index edbe9d195..1c84de5bc 100644 --- a/timed/redmine/models.py +++ b/timed/redmine/models.py @@ -11,8 +11,6 @@ class RedmineProject(models.Model): """ project = models.OneToOneField( - Project, - on_delete=models.CASCADE, - related_name='redmine_project' + Project, on_delete=models.CASCADE, related_name="redmine_project" ) issue_id = models.PositiveIntegerField() diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 0d86a2f91..68a14b853 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -16,32 +16,29 @@ def test_redmine_report(db, freezer, mocker): redmine_instance = mocker.MagicMock() issue = mocker.MagicMock() redmine_instance.issue.get.return_value = issue - redmine_class = mocker.patch('redminelib.Redmine') + redmine_class = mocker.patch("redminelib.Redmine") redmine_class.return_value = redmine_instance - freezer.move_to('2017-07-28') - report = ReportFactory.create(comment='ADSY <=> Other') + freezer.move_to("2017-07-28") + report = ReportFactory.create(comment="ADSY <=> Other") report_hours = report.duration.total_seconds() / 3600 estimated_hours = report.task.project.estimated_time.total_seconds() / 3600 RedmineProject.objects.create(project=report.task.project, issue_id=1000) # report not attached to redmine ReportFactory.create() - freezer.move_to('2017-07-31') - call_command('redmine_report', last_days=7) + freezer.move_to("2017-07-31") + call_command("redmine_report", last_days=7) redmine_instance.issue.get.assert_called_once_with(1000) - assert issue.custom_fields == [{ - 'id': 0, - 'value': report_hours - }] - assert 'Total hours: {0}'.format(report_hours) in issue.notes - assert 'Estimated hours: {0}'.format(estimated_hours) in issue.notes - assert 'Hours in last 7 days: {0}\n'.format(report_hours) in issue.notes - assert '{0}\n'.format(report.comment) in issue.notes - assert '{0}\n\n'.format(report.comment) not in issue.notes, ( - 'Only one new line after report line' - ) + assert issue.custom_fields == [{"id": 0, "value": report_hours}] + assert "Total hours: {0}".format(report_hours) in issue.notes + assert "Estimated hours: {0}".format(estimated_hours) in issue.notes + assert "Hours in last 7 days: {0}\n".format(report_hours) in issue.notes + assert "{0}\n".format(report.comment) in issue.notes + assert ( + "{0}\n\n".format(report.comment) not in issue.notes + ), "Only one new line after report line" issue.save.assert_called_once_with() @@ -49,17 +46,17 @@ def test_redmine_report_no_estimated_time(db, freezer, mocker): redmine_instance = mocker.MagicMock() issue = mocker.MagicMock() redmine_instance.issue.get.return_value = issue - redmine_class = mocker.patch('redminelib.Redmine') + redmine_class = mocker.patch("redminelib.Redmine") redmine_class.return_value = redmine_instance - freezer.move_to('2017-07-28') + freezer.move_to("2017-07-28") project = ProjectFactory.create(estimated_time=None) task = TaskFactory.create(project=project) - report = ReportFactory.create(comment='ADSY <=> Other', task=task) + report = ReportFactory.create(comment="ADSY <=> Other", task=task) RedmineProject.objects.create(project=report.task.project, issue_id=1000) - freezer.move_to('2017-07-31') - call_command('redmine_report', last_days=7) + freezer.move_to("2017-07-31") + call_command("redmine_report", last_days=7) redmine_instance.issue.get.assert_called_once_with(1000) issue.save.assert_called_once_with() @@ -68,16 +65,16 @@ def test_redmine_report_no_estimated_time(db, freezer, mocker): def test_redmine_report_invalid_issue(db, freezer, mocker, capsys): """Test case when issue is not available.""" redmine_instance = mocker.MagicMock() - redmine_class = mocker.patch('redminelib.Redmine') + redmine_class = mocker.patch("redminelib.Redmine") redmine_class.return_value = redmine_instance redmine_instance.issue.get.side_effect = ResourceNotFoundError() - freezer.move_to('2017-07-28') + freezer.move_to("2017-07-28") report = ReportFactory.create() RedmineProject.objects.create(project=report.task.project, issue_id=1000) - freezer.move_to('2017-07-31') - call_command('redmine_report', last_days=7) + freezer.move_to("2017-07-31") + call_command("redmine_report", last_days=7) _, err = capsys.readouterr() - assert 'issue 1000 assigned' in err + assert "issue 1000 assigned" in err diff --git a/timed/reports/management/commands/notify_changed_employments.py b/timed/reports/management/commands/notify_changed_employments.py index b6551f450..e561d415a 100644 --- a/timed/reports/management/commands/notify_changed_employments.py +++ b/timed/reports/management/commands/notify_changed_employments.py @@ -17,26 +17,26 @@ class Command(BaseCommand): which changed in given last days. """ - help = 'Send notification on given email address on changed employments.' + help = "Send notification on given email address on changed employments." def add_arguments(self, parser): parser.add_argument( - '--email', + "--email", type=str, - dest='email', - help='Email address notification is sent to.' + dest="email", + help="Email address notification is sent to.", ) parser.add_argument( - '--last-days', + "--last-days", default=7, type=int, - dest='last_days', - help='Time frame of last days employment changed.' + dest="last_days", + help="Time frame of last days employment changed.", ) def handle(self, *args, **options): - email = options['email'] - last_days = options['last_days'] + email = options["email"] + last_days = options["last_days"] # today is excluded end = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) @@ -45,18 +45,13 @@ def handle(self, *args, **options): employments = Employment.objects.filter(updated__range=[start, end]) if employments.exists(): from_email = settings.DEFAULT_FROM_EMAIL - subject = '[Timed] Employments changed in last {0} days'.format( - last_days - ) + subject = "[Timed] Employments changed in last {0} days".format(last_days) body = render_to_string( - 'mail/notify_changed_employments.txt', { - 'employments': employments - }, using='text' + "mail/notify_changed_employments.txt", + {"employments": employments}, + using="text", ) message = EmailMessage( - subject=subject, - body=body, - from_email=from_email, - to=[email], + subject=subject, body=body, from_email=from_email, to=[email] ) message.send() diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index 20e5697a3..6b98339ce 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -31,42 +31,42 @@ class Command(BaseCommand): projects where they are added as reviewer. """ - help = 'Notify reviewers of projects with unverified reports.' + help = "Notify reviewers of projects with unverified reports." def add_arguments(self, parser): parser.add_argument( - '--months', + "--months", default=1, type=int, - dest='months', - help='Number of months to check unverified reports in.' + dest="months", + help="Number of months to check unverified reports in.", ) parser.add_argument( - '--offset', + "--offset", default=5, type=int, - dest='offset', - help='Period will end today minus given offset.' + dest="offset", + help="Period will end today minus given offset.", ) parser.add_argument( - '--message', - default='', + "--message", + default="", type=str, - dest='message', - help='Additional message to send if there are unverified reports' + dest="message", + help="Additional message to send if there are unverified reports", ) parser.add_argument( - '--cc', - action='append', - dest='cc', - help='List of email addresses where to send a cc' + "--cc", + action="append", + dest="cc", + help="List of email addresses where to send a cc", ) def handle(self, *args, **options): - months = options['months'] - offset = options['offset'] - message = options['message'] - cc = options['cc'] + months = options["months"] + offset = options["offset"] + message = options["message"] + cc = options["cc"] today = date.today() # -1 as we also skip today @@ -85,12 +85,9 @@ def _get_unverified_reports(self, start, end): assigned but are not verified in given time frame. """ queryset = Report.objects.filter( - date__range=[start, end], - verified_by__isnull=True - ) - queryset = queryset.annotate( - num_reviewers=Count('task__project__reviewers') + date__range=[start, end], verified_by__isnull=True ) + queryset = queryset.annotate(num_reviewers=Count("task__project__reviewers")) queryset = queryset.filter(num_reviewers__gt=0) return queryset @@ -99,7 +96,7 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): """Notify reviewers on their unverified reports.""" User = get_user_model() reviewers = User.objects.all_reviewers().filter(email__isnull=False) - subject = '[Timed] Verification of reports' + subject = "[Timed] Verification of reports" from_email = settings.DEFAULT_FROM_EMAIL connection = get_connection() messages = [] @@ -107,15 +104,17 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): for reviewer in reviewers: if reports.filter(task__project__reviewers=reviewer).exists(): body = render_to_string( - 'mail/notify_reviewers_unverified.txt', { + "mail/notify_reviewers_unverified.txt", + { # we need start and end date in system format - 'start': str(start), - 'end': str(end), - 'message': optional_message, - 'reviewer': reviewer, - 'protocol': settings.HOST_PROTOCOL, - 'domain': settings.HOST_DOMAIN, - }, using='text' + "start": str(start), + "end": str(end), + "message": optional_message, + "reviewer": reviewer, + "protocol": settings.HOST_PROTOCOL, + "domain": settings.HOST_DOMAIN, + }, + using="text", ) message = EmailMessage( @@ -124,7 +123,7 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): from_email=from_email, to=[reviewer.email], cc=cc, - connection=connection + connection=connection, ) messages.append(message) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index 7553420f9..83e93f914 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -25,38 +25,38 @@ class Command(BaseCommand): expected worktime is lower than 90%. """ - help = 'Notify supervisors when supervisees have reported shortime.' + help = "Notify supervisors when supervisees have reported shortime." def add_arguments(self, parser): parser.add_argument( - '--days', + "--days", default=7, type=int, - dest='days', - help='Length of period to check shorttime in' + dest="days", + help="Length of period to check shorttime in", ) parser.add_argument( - '--offset', + "--offset", default=5, type=int, - dest='offset', - help='Period will end today minus given offset.' + dest="offset", + help="Period will end today minus given offset.", ) parser.add_argument( - '--ratio', + "--ratio", default=0.9, type=float, - dest='ratio', + dest="ratio", help=( - 'Ratio between expected and reported time ' - 'before it is considered shorttime' - ) + "Ratio between expected and reported time " + "before it is considered shorttime" + ), ) def handle(self, *args, **options): - days = options['days'] - offset = options['offset'] - ratio = options['ratio'] + days = options["days"] + offset = options["offset"] + ratio = options["ratio"] today = date.today() # -1 as we also skip today @@ -90,11 +90,11 @@ def _get_supervisees_with_shorttime(self, start, end, ratio): supervisee_ratio = reported / expected if supervisee_ratio < ratio: supervisees_shorttime[supervisee.id] = { - 'reported': self._decimal_hours(reported), - 'expected': self._decimal_hours(expected), - 'delta': self._decimal_hours(delta), - 'ratio': supervisee_ratio, - 'balance': self._decimal_hours( + "reported": self._decimal_hours(reported), + "expected": self._decimal_hours(expected), + "delta": self._decimal_hours(delta), + "ratio": supervisee_ratio, + "balance": self._decimal_hours( supervisee.calculate_worktime(start_year, end)[2] ), } @@ -110,24 +110,27 @@ def _notify_supervisors(self, start, end, ratio, supervisees): reported, expected, delta, ratio and balance """ supervisors = get_user_model().objects.all_supervisors() - subject = '[Timed] Report supervisees with shorttime' + subject = "[Timed] Report supervisees with shorttime" from_email = settings.DEFAULT_FROM_EMAIL mails = [] for supervisor in supervisors: suspects = supervisor.supervisees.filter( - id__in=supervisees.keys()).order_by('first_name') + id__in=supervisees.keys() + ).order_by("first_name") suspects_shorttime = [ (suspect, supervisees[suspect.id]) for suspect in suspects ] if suspects.count() > 0 and supervisor.email: body = render_to_string( - 'mail/notify_supervisor_shorttime.txt', { - 'start': start, - 'end': end, - 'ratio': ratio, - 'suspects': suspects_shorttime - }, using='text' + "mail/notify_supervisor_shorttime.txt", + { + "start": start, + "end": end, + "ratio": ratio, + "suspects": suspects_shorttime, + }, + using="text", ) mails.append((subject, body, from_email, [supervisor.email])) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index d10f368fa..8f3675955 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -1,7 +1,6 @@ from django.contrib.auth import get_user_model from rest_framework_json_api import relations -from rest_framework_json_api.serializers import (DurationField, IntegerField, - Serializer) +from rest_framework_json_api.serializers import DurationField, IntegerField, Serializer from timed.projects.models import Customer, Project, Task from timed.serializers import TotalTimeRootMetaMixin @@ -12,7 +11,7 @@ class YearStatisticSerializer(TotalTimeRootMetaMixin, Serializer): year = IntegerField() class Meta: - resource_name = 'year-statistics' + resource_name = "year-statistics" class MonthStatisticSerializer(TotalTimeRootMetaMixin, Serializer): @@ -21,57 +20,48 @@ class MonthStatisticSerializer(TotalTimeRootMetaMixin, Serializer): month = IntegerField() class Meta: - resource_name = 'month-statistics' + resource_name = "month-statistics" class CustomerStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() customer = relations.ResourceRelatedField( - source='task__project__customer', model=Customer, read_only=True + source="task__project__customer", model=Customer, read_only=True ) - included_serializers = { - 'customer': 'timed.projects.serializers.CustomerSerializer', - } + included_serializers = {"customer": "timed.projects.serializers.CustomerSerializer"} class Meta: - resource_name = 'customer-statistics' + resource_name = "customer-statistics" class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() project = relations.ResourceRelatedField( - source='task__project', model=Project, read_only=True + source="task__project", model=Project, read_only=True ) - included_serializers = { - 'project': 'timed.projects.serializers.ProjectSerializer', - } + included_serializers = {"project": "timed.projects.serializers.ProjectSerializer"} class Meta: - resource_name = 'project-statistics' + resource_name = "project-statistics" class TaskStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField(read_only=True) task = relations.ResourceRelatedField(model=Task, read_only=True) - included_serializers = { - 'task': 'timed.projects.serializers.TaskSerializer', - } + included_serializers = {"task": "timed.projects.serializers.TaskSerializer"} class Meta: - resource_name = 'task-statistics' + resource_name = "task-statistics" class UserStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField(read_only=True) - user = relations.ResourceRelatedField(model=get_user_model(), - read_only=True) + user = relations.ResourceRelatedField(model=get_user_model(), read_only=True) - included_serializers = { - 'user': 'timed.employment.serializers.UserSerializer', - } + included_serializers = {"user": "timed.employment.serializers.UserSerializer"} class Meta: - resource_name = 'user-statistics' + resource_name = "user-statistics" diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index eb4d0bf11..db295de37 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -10,63 +10,56 @@ def test_customer_statistic_list(auth_client, django_assert_num_queries): ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) - url = reverse('customer-statistic-list') + url = reverse("customer-statistic-list") with django_assert_num_queries(4): - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'customer' - }) + result = auth_client.get( + url, data={"ordering": "duration", "include": "customer"} + ) assert result.status_code == 200 json = result.json() expected_data = [ { - 'type': 'customer-statistics', - 'id': str(report.task.project.customer.id), - 'attributes': { - 'duration': '03:00:00' - }, - 'relationships': { - 'customer': { - 'data': { - 'id': str(report.task.project.customer.id), - 'type': 'customers' + "type": "customer-statistics", + "id": str(report.task.project.customer.id), + "attributes": {"duration": "03:00:00"}, + "relationships": { + "customer": { + "data": { + "id": str(report.task.project.customer.id), + "type": "customers", } } - } + }, }, { - 'type': 'customer-statistics', - 'id': str(report2.task.project.customer.id), - 'attributes': { - 'duration': '04:00:00' - }, - 'relationships': { - 'customer': { - 'data': { - 'id': str(report2.task.project.customer.id), - 'type': 'customers' + "type": "customer-statistics", + "id": str(report2.task.project.customer.id), + "attributes": {"duration": "04:00:00"}, + "relationships": { + "customer": { + "data": { + "id": str(report2.task.project.customer.id), + "type": "customers", } } - } - } + }, + }, ] - assert json['data'] == expected_data - assert len(json['included']) == 2 - assert json['meta']['total-time'] == '07:00:00' + assert json["data"] == expected_data + assert len(json["included"]) == 2 + assert json["meta"]["total-time"] == "07:00:00" def test_customer_statistic_detail(auth_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) - url = reverse('customer-statistic-detail', - args=[report.task.project.customer.id]) + url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) with django_assert_num_queries(3): - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'customer' - }) + result = auth_client.get( + url, data={"ordering": "duration", "include": "customer"} + ) assert result.status_code == 200 json = result.json() - assert json['data']['attributes']['duration'] == '01:00:00' + assert json["data"]["attributes"]["duration"] == "01:00:00" diff --git a/timed/reports/tests/test_month_statistic.py b/timed/reports/tests/test_month_statistic.py index 7ceaac40f..28fc5311c 100644 --- a/timed/reports/tests/test_month_statistic.py +++ b/timed/reports/tests/test_month_statistic.py @@ -10,31 +10,23 @@ def test_month_statistic_list(auth_client): ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 4)) ReportFactory.create(duration=timedelta(hours=2), date=date(2015, 12, 31)) - url = reverse('month-statistic-list') - result = auth_client.get(url, data={'ordering': 'year,month'}) + url = reverse("month-statistic-list") + result = auth_client.get(url, data={"ordering": "year,month"}) assert result.status_code == 200 json = result.json() expected_json = [ { - 'type': 'month-statistics', - 'id': '2015-12', - 'attributes': { - 'year': 2015, - 'month': 12, - 'duration': '03:00:00' - } + "type": "month-statistics", + "id": "2015-12", + "attributes": {"year": 2015, "month": 12, "duration": "03:00:00"}, }, { - 'type': 'month-statistics', - 'id': '2016-1', - 'attributes': { - 'year': 2016, - 'month': 1, - 'duration': '01:00:00' - } - } + "type": "month-statistics", + "id": "2016-1", + "attributes": {"year": 2016, "month": 1, "duration": "01:00:00"}, + }, ] - assert json['data'] == expected_json - assert json['meta']['total-time'] == '04:00:00' + assert json["data"] == expected_json + assert json["meta"]["total-time"] == "04:00:00" diff --git a/timed/reports/tests/test_notify_changed_employments.py b/timed/reports/tests/test_notify_changed_employments.py index 5a5fb3489..16cd47c45 100644 --- a/timed/reports/tests/test_notify_changed_employments.py +++ b/timed/reports/tests/test_notify_changed_employments.py @@ -6,25 +6,24 @@ def test_notify_changed_employments(db, mailoutbox, freezer): - email = 'test@example.net' + email = "test@example.net" # employments changed too far in the past - freezer.move_to('2017-08-27') + freezer.move_to("2017-08-27") EmploymentFactory.create_batch(2) # employments which should show up in report - freezer.move_to('2017-09-03') - finished = EmploymentFactory.create(end_date=date(2017, 10, 10), - percentage=80) + freezer.move_to("2017-09-03") + finished = EmploymentFactory.create(end_date=date(2017, 10, 10), percentage=80) new = EmploymentFactory.create(percentage=100) - freezer.move_to('2017-09-04') - call_command('notify_changed_employments', email=email) + freezer.move_to("2017-09-04") + call_command("notify_changed_employments", email=email) # checks assert len(mailoutbox) == 1 mail = mailoutbox[0] assert mail.to == [email] print(mail.body) - assert '80% {0}'.format(finished.user.get_full_name()) in mail.body - assert 'None 100% {0}'.format(new.user.get_full_name()) in mail.body + assert "80% {0}".format(finished.user.get_full_name()) in mail.body + assert "None 100% {0}".format(new.user.get_full_name()) in mail.body diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index a916da2a1..62712069b 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -8,13 +8,16 @@ from timed.tracking.factories import ReportFactory -@pytest.mark.freeze_time('2017-8-4') -@pytest.mark.parametrize('cc,message', [ - ('', ''), - ('example@example.com', ''), - ('example@example.com', 'This is a test'), - ('', 'This is a test') -]) +@pytest.mark.freeze_time("2017-8-4") +@pytest.mark.parametrize( + "cc,message", + [ + ("", ""), + ("example@example.com", ""), + ("example@example.com", "This is a test"), + ("", "This is a test"), + ], +) def test_notify_reviewers_with_cc_and_message(db, mailoutbox, cc, message): """Test time range 2017-7-1 till 2017-7-31.""" # a reviewer which will be notified @@ -22,21 +25,21 @@ def test_notify_reviewers_with_cc_and_message(db, mailoutbox, cc, message): project_work = ProjectFactory.create() project_work.reviewers.add(reviewer_work) task_work = TaskFactory.create(project=project_work) - ReportFactory.create(date=date(2017, 7, 1), task=task_work, - verified_by=None) + ReportFactory.create(date=date(2017, 7, 1), task=task_work, verified_by=None) # a reviewer which doesn't have any unverfied reports reviewer_no_work = UserFactory.create() project_no_work = ProjectFactory.create() project_no_work.reviewers.add(reviewer_no_work) task_no_work = TaskFactory.create(project=project_no_work) - ReportFactory.create(date=date(2017, 7, 1), task=task_no_work, - verified_by=reviewer_no_work) + ReportFactory.create( + date=date(2017, 7, 1), task=task_no_work, verified_by=reviewer_no_work + ) call_command( - 'notify_reviewers_unverified', - '--cc={0}'.format(cc), - '--message={0}'.format(message) + "notify_reviewers_unverified", + "--cc={0}".format(cc), + "--message={0}".format(message), ) # checks @@ -44,15 +47,15 @@ def test_notify_reviewers_with_cc_and_message(db, mailoutbox, cc, message): mail = mailoutbox[0] assert mail.to == [reviewer_work.email] url = ( - 'http://localhost:4200/analysis?fromDate=2017-07-01&' - 'toDate=2017-07-31&reviewer=%d&editable=1' + "http://localhost:4200/analysis?fromDate=2017-07-01&" + "toDate=2017-07-31&reviewer=%d&editable=1" ) % reviewer_work.id assert url in mail.body assert message in mail.body assert mail.cc[0] == cc -@pytest.mark.freeze_time('2017-8-4') +@pytest.mark.freeze_time("2017-8-4") def test_notify_reviewers(db, mailoutbox): """Test time range 2017-7-1 till 2017-7-31.""" # a reviewer which will be notified @@ -60,19 +63,16 @@ def test_notify_reviewers(db, mailoutbox): project_work = ProjectFactory.create() project_work.reviewers.add(reviewer_work) task_work = TaskFactory.create(project=project_work) - ReportFactory.create(date=date(2017, 7, 1), task=task_work, - verified_by=None) + ReportFactory.create(date=date(2017, 7, 1), task=task_work, verified_by=None) - call_command( - 'notify_reviewers_unverified' - ) + call_command("notify_reviewers_unverified") # checks assert len(mailoutbox) == 1 mail = mailoutbox[0] assert mail.to == [reviewer_work.email] url = ( - 'http://localhost:4200/analysis?fromDate=2017-07-01&' - 'toDate=2017-07-31&reviewer=%d&editable=1' + "http://localhost:4200/analysis?fromDate=2017-07-01&" + "toDate=2017-07-31&reviewer=%d&editable=1" ) % reviewer_work.id assert url in mail.body diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py index 03fe3a81b..be64eca21 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -9,7 +9,7 @@ from timed.tracking.factories import ReportFactory -@pytest.mark.freeze_time('2017-7-27') +@pytest.mark.freeze_time("2017-7-27") def test_notify_supervisors(db, mailoutbox): """Test time range 2017-7-17 till 2017-7-23.""" start = date(2017, 7, 14) @@ -18,28 +18,29 @@ def test_notify_supervisors(db, mailoutbox): supervisor = UserFactory.create() supervisee.supervisors.add(supervisor) - EmploymentFactory.create(user=supervisee, - start_date=start, - percentage=100) - workdays = rrule(DAILY, dtstart=start, until=date.today(), - # range is excluding last - byweekday=range(MO.weekday, FR.weekday + 1)) + EmploymentFactory.create(user=supervisee, start_date=start, percentage=100) + workdays = rrule( + DAILY, + dtstart=start, + until=date.today(), + # range is excluding last + byweekday=range(MO.weekday, FR.weekday + 1), + ) task = TaskFactory.create() for dt in workdays: - ReportFactory.create(user=supervisee, date=dt, task=task, - duration=timedelta(hours=7)) + ReportFactory.create( + user=supervisee, date=dt, task=task, duration=timedelta(hours=7) + ) - call_command('notify_supervisors_shorttime') + call_command("notify_supervisors_shorttime") # checks assert len(mailoutbox) == 1 mail = mailoutbox[0] assert mail.to == [supervisor.email] body = mail.body - assert 'Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9' in body - expected = ( - '{0} 35.0/42.5 (Ratio 0.82 Delta -7.5 Balance -9.0)' - ).format( + assert "Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9" in body + expected = ("{0} 35.0/42.5 (Ratio 0.82 Delta -7.5 Balance -9.0)").format( supervisee.get_full_name() ) assert expected in body @@ -51,6 +52,6 @@ def test_notify_supervisors_no_employment(db, mailoutbox): supervisor = UserFactory.create() supervisee.supervisors.add(supervisor) - call_command('notify_supervisors_shorttime') + call_command("notify_supervisors_shorttime") assert len(mailoutbox) == 0 diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index 5e82d8929..249fd037b 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -10,48 +10,37 @@ def test_project_statistic_list(auth_client, django_assert_num_queries): ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) - url = reverse('project-statistic-list') + url = reverse("project-statistic-list") with django_assert_num_queries(5): - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'project,project.customer' - }) + result = auth_client.get( + url, data={"ordering": "duration", "include": "project,project.customer"} + ) assert result.status_code == 200 json = result.json() expected_json = [ { - 'type': 'project-statistics', - 'id': str(report.task.project.id), - 'attributes': { - 'duration': '03:00:00' - }, - 'relationships': { - 'project': { - 'data': { - 'id': str(report.task.project.id), - 'type': 'projects' - } + "type": "project-statistics", + "id": str(report.task.project.id), + "attributes": {"duration": "03:00:00"}, + "relationships": { + "project": { + "data": {"id": str(report.task.project.id), "type": "projects"} } - } + }, }, { - 'type': 'project-statistics', - 'id': str(report2.task.project.id), - 'attributes': { - 'duration': '04:00:00' - }, - 'relationships': { - 'project': { - 'data': { - 'id': str(report2.task.project.id), - 'type': 'projects' - } + "type": "project-statistics", + "id": str(report2.task.project.id), + "attributes": {"duration": "04:00:00"}, + "relationships": { + "project": { + "data": {"id": str(report2.task.project.id), "type": "projects"} } - } - } + }, + }, ] - assert json['data'] == expected_json - assert len(json['included']) == 4 - assert json['meta']['total-time'] == '07:00:00' + assert json["data"] == expected_json + assert len(json["included"]) == 4 + assert json["meta"]["total-time"] == "07:00:00" diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index 45bbd9980..4c4098577 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -7,54 +7,43 @@ def test_task_statistic_list(auth_client, django_assert_num_queries): - task_z = TaskFactory.create(name='Z') - task_test = TaskFactory.create(name='Test') + task_z = TaskFactory.create(name="Z") + task_test = TaskFactory.create(name="Test") ReportFactory.create(duration=timedelta(hours=1), task=task_test) ReportFactory.create(duration=timedelta(hours=2), task=task_test) ReportFactory.create(duration=timedelta(hours=2), task=task_z) - url = reverse('task-statistic-list') + url = reverse("task-statistic-list") with django_assert_num_queries(5): - result = auth_client.get(url, data={ - 'ordering': 'task__name', - 'include': 'task,task.project,task.project.customer' - }) + result = auth_client.get( + url, + data={ + "ordering": "task__name", + "include": "task,task.project,task.project.customer", + }, + ) assert result.status_code == 200 json = result.json() expected_json = [ { - 'type': 'task-statistics', - 'id': str(task_test.id), - 'attributes': { - 'duration': '03:00:00' + "type": "task-statistics", + "id": str(task_test.id), + "attributes": {"duration": "03:00:00"}, + "relationships": { + "task": {"data": {"id": str(task_test.id), "type": "tasks"}} }, - 'relationships': { - 'task': { - 'data': { - 'id': str(task_test.id), - 'type': 'tasks' - } - } - } }, { - 'type': 'task-statistics', - 'id': str(task_z.id), - 'attributes': { - 'duration': '02:00:00' + "type": "task-statistics", + "id": str(task_z.id), + "attributes": {"duration": "02:00:00"}, + "relationships": { + "task": {"data": {"id": str(task_z.id), "type": "tasks"}} }, - 'relationships': { - 'task': { - 'data': { - 'id': str(task_z.id), - 'type': 'tasks' - } - } - } - } + }, ] - assert json['data'] == expected_json - assert len(json['included']) == 6 - assert json['meta']['total-time'] == '05:00:00' + assert json["data"] == expected_json + assert len(json["included"]) == 6 + assert json["meta"]["total-time"] == "05:00:00" diff --git a/timed/reports/tests/test_user_statistic.py b/timed/reports/tests/test_user_statistic.py index 644844dd1..7ef21c96e 100644 --- a/timed/reports/tests/test_user_statistic.py +++ b/timed/reports/tests/test_user_statistic.py @@ -11,47 +11,28 @@ def test_user_statistic_list(auth_client): ReportFactory.create(duration=timedelta(hours=2), user=user) report = ReportFactory.create(duration=timedelta(hours=2)) - url = reverse('user-statistic-list') - result = auth_client.get(url, data={ - 'ordering': 'duration', - 'include': 'user' - }) + url = reverse("user-statistic-list") + result = auth_client.get(url, data={"ordering": "duration", "include": "user"}) assert result.status_code == 200 json = result.json() expected_json = [ { - 'type': 'user-statistics', - 'id': str(report.user.id), - 'attributes': { - 'duration': '02:00:00' + "type": "user-statistics", + "id": str(report.user.id), + "attributes": {"duration": "02:00:00"}, + "relationships": { + "user": {"data": {"id": str(report.user.id), "type": "users"}} }, - 'relationships': { - 'user': { - 'data': { - 'id': str(report.user.id), - 'type': 'users' - } - } - } }, { - 'type': 'user-statistics', - 'id': str(user.id), - 'attributes': { - 'duration': '03:00:00' - }, - 'relationships': { - 'user': { - 'data': { - 'id': str(user.id), - 'type': 'users' - } - } - } - } + "type": "user-statistics", + "id": str(user.id), + "attributes": {"duration": "03:00:00"}, + "relationships": {"user": {"data": {"id": str(user.id), "type": "users"}}}, + }, ] - assert json['data'] == expected_json - assert len(json['included']) == 2 - assert json['meta']['total-time'] == '05:00:00' + assert json["data"] == expected_json + assert len(json["included"]) == 2 + assert json["meta"]["total-time"] == "05:00:00" diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 90dc77ed7..7c7e52b0d 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -7,96 +7,90 @@ from django.urls import reverse from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST -from timed.projects.factories import (CustomerFactory, ProjectFactory, - TaskFactory) +from timed.projects.factories import CustomerFactory, ProjectFactory, TaskFactory from timed.reports.views import WorkReportViewSet from timed.tracking.factories import ReportFactory -@pytest.mark.freeze_time('2017-09-01') +@pytest.mark.freeze_time("2017-09-01") def test_work_report_single_project(auth_client, django_assert_num_queries): user = auth_client.user # spaces should be replaced with underscore - customer = CustomerFactory.create(name='Customer Name') + customer = CustomerFactory.create(name="Customer Name") # slashes should be dropped from file name - project = ProjectFactory.create(customer=customer, name='Project/') + project = ProjectFactory.create(customer=customer, name="Project/") task = TaskFactory.create(project=project) ReportFactory.create_batch( 10, user=user, verified_by=user, task=task, date=date(2017, 8, 17) ) - url = reverse('work-report-list') + url = reverse("work-report-list") with django_assert_num_queries(4): - res = auth_client.get(url, data={ - 'user': auth_client.user.id, - 'from_date': '2017-08-01', - 'to_date': '2017-08-31', - 'verified': 1 - }) + res = auth_client.get( + url, + data={ + "user": auth_client.user.id, + "from_date": "2017-08-01", + "to_date": "2017-08-31", + "verified": 1, + }, + ) assert res.status_code == HTTP_200_OK - assert '1708-20170901-Customer_Name-Project.ods' in ( - res['Content-Disposition'] - ) + assert "1708-20170901-Customer_Name-Project.ods" in (res["Content-Disposition"]) content = io.BytesIO(res.content) doc = ezodf.opendoc(content) table = doc.sheets[0] - assert table['C5'].value == '2017-08-01' - assert table['C6'].value == '2017-08-31' - assert table['C9'].value == 'Test User' - assert table['C10'].value == 'Test User' + assert table["C5"].value == "2017-08-01" + assert table["C6"].value == "2017-08-31" + assert table["C9"].value == "Test User" + assert table["C10"].value == "Test User" -@pytest.mark.freeze_time('2017-09-01') +@pytest.mark.freeze_time("2017-09-01") def test_work_report_multiple_projects(auth_client, django_assert_num_queries): NUM_PROJECTS = 2 user = auth_client.user - customer = CustomerFactory.create(name='Customer') + customer = CustomerFactory.create(name="Customer") report_date = date(2017, 8, 17) for i in range(NUM_PROJECTS): - project = ProjectFactory.create( - customer=customer, name='Project{0}'.format(i) - ) + project = ProjectFactory.create(customer=customer, name="Project{0}".format(i)) task = TaskFactory.create(project=project) ReportFactory.create_batch(10, user=user, task=task, date=report_date) - url = reverse('work-report-list') + url = reverse("work-report-list") with django_assert_num_queries(4): - res = auth_client.get(url, data={ - 'user': auth_client.user.id, - 'verified': 0 - }) + res = auth_client.get(url, data={"user": auth_client.user.id, "verified": 0}) assert res.status_code == HTTP_200_OK - assert '20170901-WorkReports.zip' in ( - res['Content-Disposition'] - ) + assert "20170901-WorkReports.zip" in (res["Content-Disposition"]) content = io.BytesIO(res.content) - with ZipFile(content, 'r') as zipfile: + with ZipFile(content, "r") as zipfile: for i in range(NUM_PROJECTS): ods_content = zipfile.read( - '1708-20170901-Customer-Project{0}.ods'.format(i) + "1708-20170901-Customer-Project{0}.ods".format(i) ) doc = ezodf.opendoc(io.BytesIO(ods_content)) table = doc.sheets[0] - assert table['C5'].value == '2017-08-17' - assert table['C6'].value == '2017-08-17' + assert table["C5"].value == "2017-08-17" + assert table["C6"].value == "2017-08-17" def test_work_report_empty(auth_client): - url = reverse('work-report-list') - res = auth_client.get(url, data={ - 'user': auth_client.user.id - }) + url = reverse("work-report-list") + res = auth_client.get(url, data={"user": auth_client.user.id}) assert res.status_code == HTTP_400_BAD_REQUEST -@pytest.mark.parametrize('customer_name,project_name,expected', [ - ('Customer Name', 'Project/', '1708-20170818-Customer_Name-Project.ods'), - ('Customer-Name', 'Project', '1708-20170818-Customer-Name-Project.ods'), - ('Customer$Name', 'Project', '1708-20170818-CustomerName-Project.ods'), -]) +@pytest.mark.parametrize( + "customer_name,project_name,expected", + [ + ("Customer Name", "Project/", "1708-20170818-Customer_Name-Project.ods"), + ("Customer-Name", "Project", "1708-20170818-Customer-Name-Project.ods"), + ("Customer$Name", "Project", "1708-20170818-CustomerName-Project.ods"), + ], +) def test_generate_work_report_name(db, customer_name, project_name, expected): test_date = date(2017, 8, 18) view = WorkReportViewSet() diff --git a/timed/reports/tests/test_year_statistic.py b/timed/reports/tests/test_year_statistic.py index e9742ab91..e2fc404cc 100644 --- a/timed/reports/tests/test_year_statistic.py +++ b/timed/reports/tests/test_year_statistic.py @@ -10,40 +10,34 @@ def test_year_statistic_list(auth_client): ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) - url = reverse('year-statistic-list') - result = auth_client.get(url, data={'ordering': 'year'}) + url = reverse("year-statistic-list") + result = auth_client.get(url, data={"ordering": "year"}) assert result.status_code == 200 json = result.json() expected_json = [ { - 'type': 'year-statistics', - 'id': '2015', - 'attributes': { - 'year': 2015, - 'duration': '02:00:00' - } + "type": "year-statistics", + "id": "2015", + "attributes": {"year": 2015, "duration": "02:00:00"}, }, { - 'type': 'year-statistics', - 'id': '2017', - 'attributes': { - 'year': 2017, - 'duration': '01:00:00' - } - } + "type": "year-statistics", + "id": "2017", + "attributes": {"year": 2017, "duration": "01:00:00"}, + }, ] - assert json['data'] == expected_json - assert json['meta']['total-time'] == '03:00:00' + assert json["data"] == expected_json + assert json["meta"]["total-time"] == "03:00:00" def test_year_statistic_detail(auth_client): ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) - url = reverse('year-statistic-detail', args=[2015]) - result = auth_client.get(url, data={'ordering': 'year'}) + url = reverse("year-statistic-detail", args=[2015]) + result = auth_client.get(url, data={"ordering": "year"}) assert result.status_code == 200 json = result.json() - assert json['data']['attributes']['duration'] == '02:00:00' + assert json["data"]["attributes"]["duration"] == "02:00:00" diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 8330215aa..0ee974648 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -5,20 +5,12 @@ r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'work-reports', views.WorkReportViewSet, 'work-report') -r.register(r'year-statistics', views.YearStatisticViewSet, 'year-statistic') -r.register(r'month-statistics', views.MonthStatisticViewSet, 'month-statistic') -r.register(r'task-statistics', views.TaskStatisticViewSet, 'task-statistic') -r.register(r'user-statistics', views.UserStatisticViewSet, 'user-statistic') -r.register( - r'customer-statistics', - views.CustomerStatisticViewSet, - 'customer-statistic' -) -r.register( - r'project-statistics', - views.ProjectStatisticViewSet, - 'project-statistic' -) +r.register(r"work-reports", views.WorkReportViewSet, "work-report") +r.register(r"year-statistics", views.YearStatisticViewSet, "year-statistic") +r.register(r"month-statistics", views.MonthStatisticViewSet, "month-statistic") +r.register(r"task-statistics", views.TaskStatisticViewSet, "task-statistic") +r.register(r"user-statistics", views.UserStatisticViewSet, "user-statistic") +r.register(r"customer-statistics", views.CustomerStatisticViewSet, "customer-statistic") +r.register(r"project-statistics", views.ProjectStatisticViewSet, "project-statistic") urlpatterns = r.urls diff --git a/timed/reports/views.py b/timed/reports/views.py index 2a9f1e81b..603edf9d7 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -23,14 +23,14 @@ class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.YearStatisticSerializer filterset_class = ReportFilterSet - ordering_fields = ('year', 'duration') - ordering = ('year', ) + ordering_fields = ("year", "duration") + ordering = ("year",) def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.annotate(year=ExtractYear('date')).values('year') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('year')) + queryset = queryset.annotate(year=ExtractYear("date")).values("year") + queryset = queryset.annotate(duration=Sum("duration")) + queryset = queryset.annotate(pk=F("year")) return queryset @@ -39,17 +39,17 @@ class MonthStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.MonthStatisticSerializer filterset_class = ReportFilterSet - ordering_fields = ('year', 'month', 'duration') - ordering = ('year', 'month') + ordering_fields = ("year", "month", "duration") + ordering = ("year", "month") def get_queryset(self): queryset = Report.objects.all() queryset = queryset.annotate( - year=ExtractYear('date'), month=ExtractMonth('date') + year=ExtractYear("date"), month=ExtractMonth("date") ) - queryset = queryset.values('year', 'month') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=Concat('year', Value('-'), 'month')) + queryset = queryset.values("year", "month") + queryset = queryset.annotate(duration=Sum("duration")) + queryset = queryset.annotate(pk=Concat("year", Value("-"), "month")) return queryset @@ -58,15 +58,15 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.CustomerStatisticSerializer filterset_class = ReportFilterSet - ordering_fields = ('task__project__customer__name', 'duration') - ordering = ('task__project__customer__name', ) + ordering_fields = ("task__project__customer__name", "duration") + ordering = ("task__project__customer__name",) def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values('task__project__customer') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task__project__customer')) + queryset = queryset.values("task__project__customer") + queryset = queryset.annotate(duration=Sum("duration")) + queryset = queryset.annotate(pk=F("task__project__customer")) return queryset @@ -76,19 +76,17 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.ProjectStatisticSerializer filterset_class = ReportFilterSet - ordering_fields = ('task__project__name', 'duration') - ordering = ('task__project__name', ) + ordering_fields = ("task__project__name", "duration") + ordering = ("task__project__name",) - prefetch_related_for_field = { - 'task__project': ['reviewers'], - } + prefetch_related_for_field = {"task__project": ["reviewers"]} def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values('task__project') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task__project')) + queryset = queryset.values("task__project") + queryset = queryset.annotate(duration=Sum("duration")) + queryset = queryset.annotate(pk=F("task__project")) return queryset @@ -98,19 +96,17 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.TaskStatisticSerializer filterset_class = ReportFilterSet - ordering_fields = ('task__name', 'duration') - ordering = ('task__name', ) + ordering_fields = ("task__name", "duration") + ordering = ("task__name",) - prefetch_related_for_field = { - 'task': ['project__reviewers'], - } + prefetch_related_for_field = {"task": ["project__reviewers"]} def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values('task') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('task')) + queryset = queryset.values("task") + queryset = queryset.annotate(duration=Sum("duration")) + queryset = queryset.annotate(pk=F("task")) return queryset @@ -120,15 +116,15 @@ class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.UserStatisticSerializer filterset_class = ReportFilterSet - ordering_fields = ('user__username', 'duration') - ordering = ('user__username', ) + ordering_fields = ("user__username", "duration") + ordering = ("user__username",) def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values('user') - queryset = queryset.annotate(duration=Sum('duration')) - queryset = queryset.annotate(pk=F('user')) + queryset = queryset.values("user") + queryset = queryset.annotate(duration=Sum("duration")) + queryset = queryset.annotate(pk=F("user")) return queryset @@ -147,22 +143,21 @@ class WorkReportViewSet(GenericViewSet): def get_queryset(self): return Report.objects.select_related( - 'user', 'task', 'task__project', 'task__project__customer' + "user", "task", "task__project", "task__project__customer" ).prefetch_related( # need to prefetch verified_by as select_related joins nullable # foreign key verified_by with INNER JOIN instead of LEFT JOIN # which leads to an empty result. # This only happens as user and verified_by points to same table # and user is not nullable - 'verified_by' + "verified_by" ) def _parse_query_params(self, queryset, request): """Parse query params by using filterset_class.""" fltr = self.filterset_class( - request.query_params, - queryset=queryset, - request=request) + request.query_params, queryset=queryset, request=request + ) form = fltr.form form.is_valid() return form.cleaned_data @@ -174,8 +169,8 @@ def _clean_filename(self, name): To accomplish this it will remove all special chars and replace spaces with underscores """ - escaped = re.sub(r'[^\w\s-]', '', name) - return re.sub(r'\s+', '_', escaped) + escaped = re.sub(r"[^\w\s-]", "", name) + return re.sub(r"\s+", "_", escaped) def _generate_workreport_name(self, from_date, today, project): """ @@ -185,15 +180,14 @@ def _generate_workreport_name(self, from_date, today, project): whereas YYMM is year and month of from_date and YYYYMMDD is date when work reports gets created. """ - return '{0}-{1}-{2}-{3}.ods'.format( - from_date.strftime('%y%m'), - today.strftime('%Y%m%d'), + return "{0}-{1}-{2}-{3}.ods".format( + from_date.strftime("%y%m"), + today.strftime("%Y%m%d"), self._clean_filename(project.customer.name), - self._clean_filename(project.name) + self._clean_filename(project.name), ) - def _create_workreport(self, from_date, to_date, today, project, reports, - user): + def _create_workreport(self, from_date, to_date, today, project, reports, user): """ Create ods workreport. @@ -201,37 +195,39 @@ def _create_workreport(self, from_date, to_date, today, project, reports, :return: tuple where as first value is name and second ezodf document """ customer = project.customer - verifiers = sorted({ - report.verified_by.get_full_name() - for report in reports if report.verified_by_id is not None - }) + verifiers = sorted( + { + report.verified_by.get_full_name() + for report in reports + if report.verified_by_id is not None + } + ) tmpl = settings.WORK_REPORT_PATH doc = opendoc(tmpl) table = doc.sheets[0] tasks = defaultdict(int) - date_style = table['C5'].style_name + date_style = table["C5"].style_name # in template cell D3 is empty but styled for float and borders - float_style = table['D3'].style_name + float_style = table["D3"].style_name # in template cell D4 is empty but styled for text wrap and borders - text_style = table['D4'].style_name + text_style = table["D4"].style_name # in template cell D8 is empty but styled for date with borders - date_style_report = table['D8'].style_name + date_style_report = table["D8"].style_name # for simplicity insert reports in reverse order for report in reports: table.insert_rows(12, 1) - table['A13'] = Cell(report.date, - style_name=date_style_report, - value_type='date') + table["A13"] = Cell( + report.date, style_name=date_style_report, value_type="date" + ) hours = report.duration.total_seconds() / 60 / 60 - table['B13'] = Cell(hours, style_name=float_style) + table["B13"] = Cell(hours, style_name=float_style) - table['C13'] = Cell(report.user.get_full_name(), - style_name=text_style) - table['D13'] = Cell(report.task.name, style_name=text_style) - table['E13'] = Cell(report.comment, style_name=text_style) + table["C13"] = Cell(report.user.get_full_name(), style_name=text_style) + table["D13"] = Cell(report.task.name, style_name=text_style) + table["E13"] = Cell(report.comment, style_name=text_style) # when from and to date are None find lowest and biggest date from_date = min(report.date, from_date or date.max) @@ -240,35 +236,31 @@ def _create_workreport(self, from_date, to_date, today, project, reports, tasks[report.task.name] += hours # header values - table['C3'] = Cell(customer and customer.name) - table['C4'] = Cell(project and project.name) - table['C5'] = Cell(from_date, style_name=date_style, value_type='date') - table['C6'] = Cell(to_date, style_name=date_style, value_type='date') - table['C8'] = Cell(today, style_name=date_style, value_type='date') - table['C9'] = Cell(user.get_full_name()) - table['C10'] = Cell(', '.join(verifiers)) + table["C3"] = Cell(customer and customer.name) + table["C4"] = Cell(project and project.name) + table["C5"] = Cell(from_date, style_name=date_style, value_type="date") + table["C6"] = Cell(to_date, style_name=date_style, value_type="date") + table["C8"] = Cell(today, style_name=date_style, value_type="date") + table["C9"] = Cell(user.get_full_name()) + table["C10"] = Cell(", ".join(verifiers)) # reset temporary styles (mainly because of borders) - table['D3'].style_name = '' - table['D4'].style_name = '' - table['D8'].style_name = '' + table["D3"].style_name = "" + table["D4"].style_name = "" + table["D8"].style_name = "" pos = 13 + len(reports) for task_name, task_total_hours in tasks.items(): table.insert_rows(pos, 1) table.row_info(pos).style_name = table.row_info(pos - 1).style_name - table[pos, 0] = Cell( - task_name, - style_name=table[pos - 1, 0].style_name - ) + table[pos, 0] = Cell(task_name, style_name=table[pos - 1, 0].style_name) table[pos, 2] = Cell( - task_total_hours, - style_name=table[pos - 1, 2].style_name + task_total_hours, style_name=table[pos - 1, 2].style_name ) # calculate location of total hours as insert rows moved it - table[13 + len(reports) + len(tasks), 2].formula = ( - 'of:=SUM(B13:B{0})'.format(str(13 + len(reports) - 1)) + table[13 + len(reports) + len(tasks), 2].formula = "of:=SUM(B13:B{0})".format( + str(13 + len(reports) - 1) ) name = self._generate_workreport_name(from_date, today, project) @@ -283,8 +275,8 @@ def list(self, request, *args, **kwargs): return HttpResponseBadRequest() params = self._parse_query_params(queryset, request) - from_date = params.get('from_date') - to_date = params.get('to_date') + from_date = params.get("from_date") + to_date = params.get("to_date") today = date.today() reports_by_project = defaultdict(list) @@ -302,21 +294,18 @@ def list(self, request, *args, **kwargs): name, doc = docs[0] response = HttpResponse( doc.tobytes(), - content_type='application/vnd.oasis.opendocument.spreadsheet') - response['Content-Disposition'] = ( - 'attachment; filename=%s' % name + content_type="application/vnd.oasis.opendocument.spreadsheet", ) + response["Content-Disposition"] = "attachment; filename=%s" % name return response # zip multiple work reports buf = BytesIO() - with ZipFile(buf, 'w') as zf: + with ZipFile(buf, "w") as zf: for name, doc in docs: zf.writestr(name, doc.tobytes()) - response = HttpResponse(buf.getvalue(), content_type='application/zip') - response['Content-Disposition'] = ( - 'attachment; filename=%s-WorkReports.zip' % ( - today.strftime('%Y%m%d') - ) + response = HttpResponse(buf.getvalue(), content_type="application/zip") + response["Content-Disposition"] = "attachment; filename=%s-WorkReports.zip" % ( + today.strftime("%Y%m%d") ) return response diff --git a/timed/serializers.py b/timed/serializers.py index ae36977f1..0370b15b9 100644 --- a/timed/serializers.py +++ b/timed/serializers.py @@ -5,17 +5,15 @@ class TotalTimeRootMetaMixin(object): - duration_field = 'duration' + duration_field = "duration" def get_root_meta(self, resource, many): """Add total hours over whole result (not just page) to meta.""" if many: - view = self.context['view'] + view = self.context["view"] queryset = view.filter_queryset(view.get_queryset()) data = queryset.aggregate(total_time=Sum(self.duration_field)) - data['total_time'] = duration_string( - data['total_time'] or timedelta(0) - ) + data["total_time"] = duration_string(data["total_time"] or timedelta(0)) return data return {} diff --git a/timed/settings.py b/timed/settings.py index b4f87423e..3bf154ea1 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -9,245 +9,212 @@ django_root = environ.Path(__file__) - 2 -ENV_FILE = env.str('DJANGO_ENV_FILE', default=django_root('.env')) +ENV_FILE = env.str("DJANGO_ENV_FILE", default=django_root(".env")) if os.path.exists(ENV_FILE): environ.Env.read_env(ENV_FILE) # per default production is enabled for security reasons # for development create .env file with ENV=dev -ENV = env.str('ENV', 'prod') +ENV = env.str("ENV", "prod") def default(default_dev=env.NOTSET, default_prod=env.NOTSET): """Environment aware default.""" - return default_prod if ENV == 'prod' else default_dev + return default_prod if ENV == "prod" else default_dev # Database definition DATABASES = { - 'default': { - 'ENGINE': env.str( - 'DJANGO_DATABASE_ENGINE', - default='django.db.backends.postgresql_psycopg2' + "default": { + "ENGINE": env.str( + "DJANGO_DATABASE_ENGINE", default="django.db.backends.postgresql_psycopg2" ), - 'NAME': env.str('DJANGO_DATABASE_NAME', default='timed'), - 'USER': env.str('DJANGO_DATABASE_USER', default='timed'), - 'PASSWORD': env.str( - 'DJANGO_DATABASE_PASSWORD', default=default('timed') - ), - 'HOST': env.str('DJANGO_DATABASE_HOST', default='localhost'), - 'PORT': env.str('DJANGO_DATABASE_PORT', default='') + "NAME": env.str("DJANGO_DATABASE_NAME", default="timed"), + "USER": env.str("DJANGO_DATABASE_USER", default="timed"), + "PASSWORD": env.str("DJANGO_DATABASE_PASSWORD", default=default("timed")), + "HOST": env.str("DJANGO_DATABASE_HOST", default="localhost"), + "PORT": env.str("DJANGO_DATABASE_PORT", default=""), } } # Application definition -DEBUG = env.bool('DJANGO_DEBUG', default=default(True, False)) -SECRET_KEY = env.str('DJANGO_SECRET_KEY', default=default('uuuuuuuuuu')) -ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=default(['*'])) -HOST_PROTOCOL = env.str('DJANGO_HOST_PROTOCOL', default=default('http')) -HOST_DOMAIN = env.str('DJANGO_HOST_DOMAIN', default=default('localhost:4200')) +DEBUG = env.bool("DJANGO_DEBUG", default=default(True, False)) +SECRET_KEY = env.str("DJANGO_SECRET_KEY", default=default("uuuuuuuuuu")) +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=default(["*"])) +HOST_PROTOCOL = env.str("DJANGO_HOST_PROTOCOL", default=default("http")) +HOST_DOMAIN = env.str("DJANGO_HOST_DOMAIN", default=default("localhost:4200")) INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.humanize', - 'multiselectfield', - 'django.forms', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'django_filters', - 'timed.employment', - 'timed.projects', - 'timed.tracking', - 'timed.reports', - 'timed.redmine', - 'timed.subscription', + "django.contrib.admin", + "django.contrib.humanize", + "multiselectfield", + "django.forms", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "django_filters", + "timed.employment", + "timed.projects", + "timed.tracking", + "timed.reports", + "timed.redmine", + "timed.subscription", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'timed.urls' +ROOT_URLCONF = "timed.urls" -FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [django_root('timed', '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', - ], + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [django_root("timed", "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", + ] }, }, # template backend for plain text (no escaping) { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [django_root('timed', 'templates')], - 'NAME': 'text', - 'APP_DIRS': True, - 'OPTIONS': { - 'autoescape': False, - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [django_root("timed", "templates")], + "NAME": "text", + "APP_DIRS": True, + "OPTIONS": { + "autoescape": False, + "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 = 'timed.wsgi.application' +WSGI_APPLICATION = "timed.wsgi.application" # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ -LOCALE_PATHS = [ - django_root('timed', 'locale') -] +LOCALE_PATHS = [django_root("timed", "locale")] -LANGUAGE_CODE = 'en-US' +LANGUAGE_CODE = "en-US" -TIME_ZONE = env.str('DJANGO_TIME_ZONE', 'Europe/Zurich') +TIME_ZONE = env.str("DJANGO_TIME_ZONE", "Europe/Zurich") USE_I18N = True USE_L10N = True -DATETIME_FORMAT = env.str('DJANGO_DATETIME_FORMAT', 'd.m.Y H:i:s') -DATE_FORMAT = env.str('DJANGO_DATE_FORMAT', 'd.m.Y') -TIME_FORMAT = env.str('DJANGO_TIME_FORMAT', 'H:i:s') +DATETIME_FORMAT = env.str("DJANGO_DATETIME_FORMAT", "d.m.Y H:i:s") +DATE_FORMAT = env.str("DJANGO_DATE_FORMAT", "d.m.Y") +TIME_FORMAT = env.str("DJANGO_TIME_FORMAT", "H:i:s") -DECIMAL_SEPARATOR = env.str('DECIMAL_SEPARATOR', '.') +DECIMAL_SEPARATOR = env.str("DECIMAL_SEPARATOR", ".") USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ -STATIC_URL = env.str('STATIC_URL', '/static/') -STATIC_ROOT = env.str('STATIC_ROOT', None) +STATIC_URL = env.str("STATIC_URL", "/static/") +STATIC_ROOT = env.str("STATIC_ROOT", None) # Rest framework definition REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ( - 'django_filters.rest_framework.DjangoFilterBackend', - 'rest_framework.filters.SearchFilter', - 'rest_framework.filters.OrderingFilter', - ), - 'DEFAULT_PARSER_CLASSES': ( - 'rest_framework_json_api.parsers.JSONParser', + "DEFAULT_FILTER_BACKENDS": ( + "django_filters.rest_framework.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", + "rest_framework.filters.OrderingFilter", ), - 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', - ), - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', - ), - 'DEFAULT_METADATA_CLASS': - 'rest_framework_json_api.metadata.JSONAPIMetadata', - 'EXCEPTION_HANDLER': - 'rest_framework_json_api.exceptions.exception_handler', - 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.PageNumberPagination', - 'DEFAULT_RENDERER_CLASSES': ( - 'rest_framework_json_api.renderers.JSONRenderer', + "DEFAULT_PARSER_CLASSES": ("rest_framework_json_api.parsers.JSONParser",), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_jwt.authentication.JSONWebTokenAuthentication", + "rest_framework.authentication.SessionAuthentication", ), + "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", + "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", + "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.PageNumberPagination", + "DEFAULT_RENDERER_CLASSES": ("rest_framework_json_api.renderers.JSONRenderer",), } -JSON_API_FORMAT_FIELD_NAMES = 'dasherize' -JSON_API_FORMAT_TYPES = 'dasherize' +JSON_API_FORMAT_FIELD_NAMES = "dasherize" +JSON_API_FORMAT_TYPES = "dasherize" JSON_API_PLURALIZE_TYPES = True APPEND_SLASH = False # Authentication definition -AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', -] +AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] -AUTH_LDAP_ENABLED = env.bool('DJANGO_AUTH_LDAP_ENABLED', default=False) +AUTH_LDAP_ENABLED = env.bool("DJANGO_AUTH_LDAP_ENABLED", default=False) if AUTH_LDAP_ENABLED: AUTH_LDAP_USER_ATTR_MAP = env.dict( - 'DJANGO_AUTH_LDAP_USER_ATTR_MAP', - default={ - 'first_name': 'givenName', - 'last_name': 'sn', - 'email': 'mail' - } + "DJANGO_AUTH_LDAP_USER_ATTR_MAP", + default={"first_name": "givenName", "last_name": "sn", "email": "mail"}, ) - AUTH_LDAP_SERVER_URI = env.str('DJANGO_AUTH_LDAP_SERVER_URI') - AUTH_LDAP_BIND_DN = env.str('DJANGO_AUTH_LDAP_BIND_DN', default='') - AUTH_LDAP_BIND_PASSWORD = env.str( - 'DJANGO_AUTH_LDAP_BIND_PASSWORD', default='' - ) - AUTH_LDAP_USER_DN_TEMPLATE = env.str('DJANGO_AUTH_LDAP_USER_DN_TEMPLATE') - AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend') + AUTH_LDAP_SERVER_URI = env.str("DJANGO_AUTH_LDAP_SERVER_URI") + AUTH_LDAP_BIND_DN = env.str("DJANGO_AUTH_LDAP_BIND_DN", default="") + AUTH_LDAP_BIND_PASSWORD = env.str("DJANGO_AUTH_LDAP_BIND_PASSWORD", default="") + AUTH_LDAP_USER_DN_TEMPLATE = env.str("DJANGO_AUTH_LDAP_USER_DN_TEMPLATE") + AUTHENTICATION_BACKENDS.insert(0, "django_auth_ldap.backend.LDAPBackend") -AUTH_USER_MODEL = 'employment.User' +AUTH_USER_MODEL = "employment.User" JWT_AUTH = { - 'JWT_EXPIRATION_DELTA': datetime.timedelta(days=2), - 'JWT_ALLOW_REFRESH': True, - 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7), - 'JWT_AUTH_HEADER_PREFIX': 'Bearer', + "JWT_EXPIRATION_DELTA": datetime.timedelta(days=2), + "JWT_ALLOW_REFRESH": True, + "JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=7), + "JWT_AUTH_HEADER_PREFIX": "Bearer", } AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" # noqa }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, # noqa + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, # noqa { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator" # noqa }, ] # Email definition -EMAIL_CONFIG = env.email_url( - 'EMAIL_URL', - default='smtp://localhost:25' -) +EMAIL_CONFIG = env.email_url("EMAIL_URL", default="smtp://localhost:25") vars().update(EMAIL_CONFIG) DEFAULT_FROM_EMAIL = env.str( - 'DJANGO_DEFAULT_FROM_EMAIL', - default('webmaster@localhost') + "DJANGO_DEFAULT_FROM_EMAIL", default("webmaster@localhost") ) -SERVER_EMAIL = env.str( - 'DJANGO_SERVER_EMAIL', - default('root@localhost') -) +SERVER_EMAIL = env.str("DJANGO_SERVER_EMAIL", default("root@localhost")) def parse_admins(admins): @@ -259,32 +226,31 @@ def parse_admins(admins): """ result = [] for admin in admins: - match = re.search(r'(.+) \<(.+@.+)\>', admin) + match = re.search(r"(.+) \<(.+@.+)\>", admin) if not match: raise environ.ImproperlyConfigured( 'In DJANGO_ADMINS admin "{0}" is not in correct ' - '"Firstname Lastname "'.format(admin)) + '"Firstname Lastname "'.format(admin) + ) result.append((match.group(1), match.group(2))) return result -ADMINS = parse_admins(env.list('DJANGO_ADMINS', default=[])) +ADMINS = parse_admins(env.list("DJANGO_ADMINS", default=[])) # Redmine definition (optional) -REDMINE_URL = env.str('DJANGO_REDMINE_URL', default='') -REDMINE_APIKEY = env.str('DJANGO_REDMINE_APIKEY', default='') -REDMINE_HTACCESS_USER = env.str('DJANGO_REDMINE_HTACCESS_USER', default='') -REDMINE_HTACCESS_PASSWORD = env.str( - 'DJANGO_REDMINE_HTACCESS_PASSWORD', default='') -REDMINE_SPENTHOURS_FIELD = env.int( - 'DJANGO_REDMINE_SPENTHOURS_FIELD', default=0) +REDMINE_URL = env.str("DJANGO_REDMINE_URL", default="") +REDMINE_APIKEY = env.str("DJANGO_REDMINE_APIKEY", default="") +REDMINE_HTACCESS_USER = env.str("DJANGO_REDMINE_HTACCESS_USER", default="") +REDMINE_HTACCESS_PASSWORD = env.str("DJANGO_REDMINE_HTACCESS_PASSWORD", default="") +REDMINE_SPENTHOURS_FIELD = env.int("DJANGO_REDMINE_SPENTHOURS_FIELD", default=0) # Work report definition WORK_REPORT_PATH = env.str( - 'DJANGO_WORK_REPORT_PATH', - default=resource_filename('timed.reports', 'workreport.ots') + "DJANGO_WORK_REPORT_PATH", + default=resource_filename("timed.reports", "workreport.ots"), ) diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py index 9af02bcbc..f0f0a06b1 100644 --- a/timed/subscription/admin.py +++ b/timed/subscription/admin.py @@ -11,24 +11,20 @@ class PackageForm(forms.ModelForm): model = models.Package - duration = DurationInHoursField( - label=_('Duration in hours'), - required=True, - ) + duration = DurationInHoursField(label=_("Duration in hours"), required=True) @admin.register(models.Package) class PackageAdmin(admin.ModelAdmin): - list_display = ['billing_type', 'duration', 'price'] + list_display = ["billing_type", "duration", "price"] form = PackageForm class CustomerPasswordForm(forms.ModelForm): def save(self, commit=True): - password = self.cleaned_data.get('password') + password = self.cleaned_data.get("password") if password is not None: - self.instance.password = hashlib.md5( - password.encode()).hexdigest() + self.instance.password = hashlib.md5(password.encode()).hexdigest() return super().save(commit=commit) diff --git a/timed/subscription/factories.py b/timed/subscription/factories.py index 200ab3e27..b826ef0f7 100644 --- a/timed/subscription/factories.py +++ b/timed/subscription/factories.py @@ -5,17 +5,17 @@ class OrderFactory(DjangoModelFactory): - project = SubFactory('timed.projects.factories.ProjectFactory') - duration = Faker('time_delta') + project = SubFactory("timed.projects.factories.ProjectFactory") + duration = Faker("time_delta") class Meta: model = models.Order class PackageFactory(DjangoModelFactory): - billing_type = SubFactory('timed.projects.factories.BillingTypeFactory') - duration = Faker('time_delta') - price = Faker('pydecimal', positive=True, left_digits=4, right_digits=2) + billing_type = SubFactory("timed.projects.factories.BillingTypeFactory") + duration = Faker("time_delta") + price = Faker("pydecimal", positive=True, left_digits=4, right_digits=2) class Meta: model = models.Package diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index d62189115..986dd3954 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -6,32 +6,21 @@ class PackageFilter(FilterSet): - customer = NumberFilter(method='filter_customer') + customer = NumberFilter(method="filter_customer") def filter_customer(self, queryset, name, value): - billing_types = Project.objects.filter( - customer=value - ).values( - 'billing_type' - ) + billing_types = Project.objects.filter(customer=value).values("billing_type") return queryset.filter(billing_type__in=billing_types) class Meta: model = models.Package - fields = ( - 'billing_type', - 'customer', - ) + fields = ("billing_type", "customer") class OrderFilter(FilterSet): - customer = NumberFilter(field_name='project__customer') - acknowledged = NumberFilter(field_name='acknowledged') + customer = NumberFilter(field_name="project__customer") + acknowledged = NumberFilter(field_name="acknowledged") class Meta: model = models.Order - fields = ( - 'customer', - 'project', - 'acknowledged' - ) + fields = ("customer", "project", "acknowledged") diff --git a/timed/subscription/migrations/0001_initial.py b/timed/subscription/migrations/0001_initial.py index a4d386549..57528534c 100644 --- a/timed/subscription/migrations/0001_initial.py +++ b/timed/subscription/migrations/0001_initial.py @@ -16,57 +16,354 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('projects', '0001_initial'), + ("projects", "0001_initial"), ] operations = [ migrations.CreateModel( - name='CustomerPassword', + name="CustomerPassword", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(blank=True, max_length=128, null=True, verbose_name='password')), - ('customer', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='projects.Customer')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "password", + models.CharField( + blank=True, max_length=128, null=True, verbose_name="password" + ), + ), + ( + "customer", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="projects.Customer", + ), + ), ], ), migrations.CreateModel( - name='Order', + name="Order", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('duration', models.DurationField()), - ('ordered', models.DateTimeField(default=django.utils.timezone.now)), - ('acknowledged', models.BooleanField(default=False)), - ('confirmedby', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders_confirmed', to=settings.AUTH_USER_MODEL)), - ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='projects.Project')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("duration", models.DurationField()), + ("ordered", models.DateTimeField(default=django.utils.timezone.now)), + ("acknowledged", models.BooleanField(default=False)), + ( + "confirmedby", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="orders_confirmed", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription", + to="projects.Project", + ), + ), ], ), migrations.CreateModel( - name='Package', + name="Package", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('duration', models.DurationField()), - ('price_currency', djmoney.models.fields.CurrencyField(choices=[('XUA', 'ADB Unit of Account'), ('AFN', 'Afghani'), ('DZD', 'Algerian Dinar'), ('ARS', 'Argentine Peso'), ('AMD', 'Armenian Dram'), ('AWG', 'Aruban Guilder'), ('AUD', 'Australian Dollar'), ('AZN', 'Azerbaijanian Manat'), ('BSD', 'Bahamian Dollar'), ('BHD', 'Bahraini Dinar'), ('THB', 'Baht'), ('PAB', 'Balboa'), ('BBD', 'Barbados Dollar'), ('BYN', 'Belarussian Ruble'), ('BYR', 'Belarussian Ruble'), ('BZD', 'Belize Dollar'), ('BMD', 'Bermudian Dollar (customarily known as Bermuda Dollar)'), ('BTN', 'Bhutanese ngultrum'), ('VEF', 'Bolivar Fuerte'), ('BOB', 'Boliviano'), ('XBA', 'Bond Markets Units European Composite Unit (EURCO)'), ('BRL', 'Brazilian Real'), ('BND', 'Brunei Dollar'), ('BGN', 'Bulgarian Lev'), ('BIF', 'Burundi Franc'), ('XOF', 'CFA Franc BCEAO'), ('XAF', 'CFA franc BEAC'), ('XPF', 'CFP Franc'), ('CAD', 'Canadian Dollar'), ('CVE', 'Cape Verde Escudo'), ('KYD', 'Cayman Islands Dollar'), ('CLP', 'Chilean peso'), ('XTS', 'Codes specifically reserved for testing purposes'), ('COP', 'Colombian peso'), ('KMF', 'Comoro Franc'), ('CDF', 'Congolese franc'), ('BAM', 'Convertible Marks'), ('NIO', 'Cordoba Oro'), ('CRC', 'Costa Rican Colon'), ('HRK', 'Croatian Kuna'), ('CUP', 'Cuban Peso'), ('CUC', 'Cuban convertible peso'), ('CZK', 'Czech Koruna'), ('GMD', 'Dalasi'), ('DKK', 'Danish Krone'), ('MKD', 'Denar'), ('DJF', 'Djibouti Franc'), ('STD', 'Dobra'), ('DOP', 'Dominican Peso'), ('VND', 'Dong'), ('XCD', 'East Caribbean Dollar'), ('EGP', 'Egyptian Pound'), ('SVC', 'El Salvador Colon'), ('ETB', 'Ethiopian Birr'), ('EUR', 'Euro'), ('XBB', 'European Monetary Unit (E.M.U.-6)'), ('XBD', 'European Unit of Account 17(E.U.A.-17)'), ('XBC', 'European Unit of Account 9(E.U.A.-9)'), ('FKP', 'Falkland Islands Pound'), ('FJD', 'Fiji Dollar'), ('HUF', 'Forint'), ('GHS', 'Ghana Cedi'), ('GIP', 'Gibraltar Pound'), ('XAU', 'Gold'), ('XFO', 'Gold-Franc'), ('PYG', 'Guarani'), ('GNF', 'Guinea Franc'), ('GYD', 'Guyana Dollar'), ('HTG', 'Haitian gourde'), ('HKD', 'Hong Kong Dollar'), ('UAH', 'Hryvnia'), ('ISK', 'Iceland Krona'), ('INR', 'Indian Rupee'), ('IRR', 'Iranian Rial'), ('IQD', 'Iraqi Dinar'), ('IMP', 'Isle of Man Pound'), ('JMD', 'Jamaican Dollar'), ('JOD', 'Jordanian Dinar'), ('KES', 'Kenyan Shilling'), ('PGK', 'Kina'), ('LAK', 'Kip'), ('KWD', 'Kuwaiti Dinar'), ('AOA', 'Kwanza'), ('MMK', 'Kyat'), ('GEL', 'Lari'), ('LVL', 'Latvian Lats'), ('LBP', 'Lebanese Pound'), ('ALL', 'Lek'), ('HNL', 'Lempira'), ('SLL', 'Leone'), ('LSL', 'Lesotho loti'), ('LRD', 'Liberian Dollar'), ('LYD', 'Libyan Dinar'), ('SZL', 'Lilangeni'), ('LTL', 'Lithuanian Litas'), ('MGA', 'Malagasy Ariary'), ('MWK', 'Malawian Kwacha'), ('MYR', 'Malaysian Ringgit'), ('TMM', 'Manat'), ('MUR', 'Mauritius Rupee'), ('MZN', 'Metical'), ('MXV', 'Mexican Unidad de Inversion (UDI)'), ('MXN', 'Mexican peso'), ('MDL', 'Moldovan Leu'), ('MAD', 'Moroccan Dirham'), ('BOV', 'Mvdol'), ('NGN', 'Naira'), ('ERN', 'Nakfa'), ('NAD', 'Namibian Dollar'), ('NPR', 'Nepalese Rupee'), ('ANG', 'Netherlands Antillian Guilder'), ('ILS', 'New Israeli Sheqel'), ('RON', 'New Leu'), ('TWD', 'New Taiwan Dollar'), ('NZD', 'New Zealand Dollar'), ('KPW', 'North Korean Won'), ('NOK', 'Norwegian Krone'), ('PEN', 'Nuevo Sol'), ('MRO', 'Ouguiya'), ('TOP', 'Paanga'), ('PKR', 'Pakistan Rupee'), ('XPD', 'Palladium'), ('MOP', 'Pataca'), ('PHP', 'Philippine Peso'), ('XPT', 'Platinum'), ('GBP', 'Pound Sterling'), ('BWP', 'Pula'), ('QAR', 'Qatari Rial'), ('GTQ', 'Quetzal'), ('ZAR', 'Rand'), ('OMR', 'Rial Omani'), ('KHR', 'Riel'), ('MVR', 'Rufiyaa'), ('IDR', 'Rupiah'), ('RUB', 'Russian Ruble'), ('RWF', 'Rwanda Franc'), ('XDR', 'SDR'), ('SHP', 'Saint Helena Pound'), ('SAR', 'Saudi Riyal'), ('RSD', 'Serbian Dinar'), ('SCR', 'Seychelles Rupee'), ('XAG', 'Silver'), ('SGD', 'Singapore Dollar'), ('SBD', 'Solomon Islands Dollar'), ('KGS', 'Som'), ('SOS', 'Somali Shilling'), ('TJS', 'Somoni'), ('SSP', 'South Sudanese Pound'), ('LKR', 'Sri Lanka Rupee'), ('XSU', 'Sucre'), ('SDG', 'Sudanese Pound'), ('SRD', 'Surinam Dollar'), ('SEK', 'Swedish Krona'), ('CHF', 'Swiss Franc'), ('SYP', 'Syrian Pound'), ('BDT', 'Taka'), ('WST', 'Tala'), ('TZS', 'Tanzanian Shilling'), ('KZT', 'Tenge'), ('XXX', 'The codes assigned for transactions where no currency is involved'), ('TTD', 'Trinidad and Tobago Dollar'), ('MNT', 'Tugrik'), ('TND', 'Tunisian Dinar'), ('TRY', 'Turkish Lira'), ('TMT', 'Turkmenistan New Manat'), ('TVD', 'Tuvalu dollar'), ('AED', 'UAE Dirham'), ('XFU', 'UIC-Franc'), ('USD', 'US Dollar'), ('USN', 'US Dollar (Next day)'), ('UGX', 'Uganda Shilling'), ('CLF', 'Unidad de Fomento'), ('COU', 'Unidad de Valor Real'), ('UYI', 'Uruguay Peso en Unidades Indexadas (URUIURUI)'), ('UYU', 'Uruguayan peso'), ('UZS', 'Uzbekistan Sum'), ('VUV', 'Vatu'), ('CHE', 'WIR Euro'), ('CHW', 'WIR Franc'), ('KRW', 'Won'), ('YER', 'Yemeni Rial'), ('JPY', 'Yen'), ('CNY', 'Yuan Renminbi'), ('ZMK', 'Zambian Kwacha'), ('ZMW', 'Zambian Kwacha'), ('ZWD', 'Zimbabwe Dollar A/06'), ('ZWN', 'Zimbabwe dollar A/08'), ('ZWL', 'Zimbabwe dollar A/09'), ('PLN', 'Zloty')], default='CHF', editable=False, max_length=3)), - ('price', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), default_currency='CHF', max_digits=7)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("duration", models.DurationField()), + ( + "price_currency", + djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghani"), + ("DZD", "Algerian Dinar"), + ("ARS", "Argentine Peso"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Guilder"), + ("AUD", "Australian Dollar"), + ("AZN", "Azerbaijanian Manat"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("THB", "Baht"), + ("PAB", "Balboa"), + ("BBD", "Barbados Dollar"), + ("BYN", "Belarussian Ruble"), + ("BYR", "Belarussian Ruble"), + ("BZD", "Belize Dollar"), + ( + "BMD", + "Bermudian Dollar (customarily known as Bermuda Dollar)", + ), + ("BTN", "Bhutanese ngultrum"), + ("VEF", "Bolivar Fuerte"), + ("BOB", "Boliviano"), + ( + "XBA", + "Bond Markets Units European Composite Unit (EURCO)", + ), + ("BRL", "Brazilian Real"), + ("BND", "Brunei Dollar"), + ("BGN", "Bulgarian Lev"), + ("BIF", "Burundi Franc"), + ("XOF", "CFA Franc BCEAO"), + ("XAF", "CFA franc BEAC"), + ("XPF", "CFP Franc"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verde Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("CLP", "Chilean peso"), + ("XTS", "Codes specifically reserved for testing purposes"), + ("COP", "Colombian peso"), + ("KMF", "Comoro Franc"), + ("CDF", "Congolese franc"), + ("BAM", "Convertible Marks"), + ("NIO", "Cordoba Oro"), + ("CRC", "Costa Rican Colon"), + ("HRK", "Croatian Kuna"), + ("CUP", "Cuban Peso"), + ("CUC", "Cuban convertible peso"), + ("CZK", "Czech Koruna"), + ("GMD", "Dalasi"), + ("DKK", "Danish Krone"), + ("MKD", "Denar"), + ("DJF", "Djibouti Franc"), + ("STD", "Dobra"), + ("DOP", "Dominican Peso"), + ("VND", "Dong"), + ("XCD", "East Caribbean Dollar"), + ("EGP", "Egyptian Pound"), + ("SVC", "El Salvador Colon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBB", "European Monetary Unit (E.M.U.-6)"), + ("XBD", "European Unit of Account 17(E.U.A.-17)"), + ("XBC", "European Unit of Account 9(E.U.A.-9)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fiji Dollar"), + ("HUF", "Forint"), + ("GHS", "Ghana Cedi"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("XFO", "Gold-Franc"), + ("PYG", "Guarani"), + ("GNF", "Guinea Franc"), + ("GYD", "Guyana Dollar"), + ("HTG", "Haitian gourde"), + ("HKD", "Hong Kong Dollar"), + ("UAH", "Hryvnia"), + ("ISK", "Iceland Krona"), + ("INR", "Indian Rupee"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IMP", "Isle of Man Pound"), + ("JMD", "Jamaican Dollar"), + ("JOD", "Jordanian Dinar"), + ("KES", "Kenyan Shilling"), + ("PGK", "Kina"), + ("LAK", "Kip"), + ("KWD", "Kuwaiti Dinar"), + ("AOA", "Kwanza"), + ("MMK", "Kyat"), + ("GEL", "Lari"), + ("LVL", "Latvian Lats"), + ("LBP", "Lebanese Pound"), + ("ALL", "Lek"), + ("HNL", "Lempira"), + ("SLL", "Leone"), + ("LSL", "Lesotho loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("SZL", "Lilangeni"), + ("LTL", "Lithuanian Litas"), + ("MGA", "Malagasy Ariary"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("TMM", "Manat"), + ("MUR", "Mauritius Rupee"), + ("MZN", "Metical"), + ("MXV", "Mexican Unidad de Inversion (UDI)"), + ("MXN", "Mexican peso"), + ("MDL", "Moldovan Leu"), + ("MAD", "Moroccan Dirham"), + ("BOV", "Mvdol"), + ("NGN", "Naira"), + ("ERN", "Nakfa"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillian Guilder"), + ("ILS", "New Israeli Sheqel"), + ("RON", "New Leu"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("PEN", "Nuevo Sol"), + ("MRO", "Ouguiya"), + ("TOP", "Paanga"), + ("PKR", "Pakistan Rupee"), + ("XPD", "Palladium"), + ("MOP", "Pataca"), + ("PHP", "Philippine Peso"), + ("XPT", "Platinum"), + ("GBP", "Pound Sterling"), + ("BWP", "Pula"), + ("QAR", "Qatari Rial"), + ("GTQ", "Quetzal"), + ("ZAR", "Rand"), + ("OMR", "Rial Omani"), + ("KHR", "Riel"), + ("MVR", "Rufiyaa"), + ("IDR", "Rupiah"), + ("RUB", "Russian Ruble"), + ("RWF", "Rwanda Franc"), + ("XDR", "SDR"), + ("SHP", "Saint Helena Pound"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("SCR", "Seychelles Rupee"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SBD", "Solomon Islands Dollar"), + ("KGS", "Som"), + ("SOS", "Somali Shilling"), + ("TJS", "Somoni"), + ("SSP", "South Sudanese Pound"), + ("LKR", "Sri Lanka Rupee"), + ("XSU", "Sucre"), + ("SDG", "Sudanese Pound"), + ("SRD", "Surinam Dollar"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("BDT", "Taka"), + ("WST", "Tala"), + ("TZS", "Tanzanian Shilling"), + ("KZT", "Tenge"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TTD", "Trinidad and Tobago Dollar"), + ("MNT", "Tugrik"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TMT", "Turkmenistan New Manat"), + ("TVD", "Tuvalu dollar"), + ("AED", "UAE Dirham"), + ("XFU", "UIC-Franc"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("UGX", "Uganda Shilling"), + ("CLF", "Unidad de Fomento"), + ("COU", "Unidad de Valor Real"), + ("UYI", "Uruguay Peso en Unidades Indexadas (URUIURUI)"), + ("UYU", "Uruguayan peso"), + ("UZS", "Uzbekistan Sum"), + ("VUV", "Vatu"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("KRW", "Won"), + ("YER", "Yemeni Rial"), + ("JPY", "Yen"), + ("CNY", "Yuan Renminbi"), + ("ZMK", "Zambian Kwacha"), + ("ZMW", "Zambian Kwacha"), + ("ZWD", "Zimbabwe Dollar A/06"), + ("ZWN", "Zimbabwe dollar A/08"), + ("ZWL", "Zimbabwe dollar A/09"), + ("PLN", "Zloty"), + ], + default="CHF", + editable=False, + max_length=3, + ), + ), + ( + "price", + djmoney.models.fields.MoneyField( + decimal_places=2, + default=Decimal("0.0"), + default_currency="CHF", + max_digits=7, + ), + ), ], ), migrations.CreateModel( - name='Subscription', + name="Subscription", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('archived', models.BooleanField(default=False)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("archived", models.BooleanField(default=False)), ], ), migrations.CreateModel( - name='SubscriptionProject', + name="SubscriptionProject", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='projects.Project')), - ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='subscription.Subscription')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="projects.Project", + ), + ), + ( + "subscription", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="subscription.Subscription", + ), + ), ], ), migrations.AddField( - model_name='package', - name='subscription', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='subscription.Subscription'), + model_name="package", + name="subscription", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="subscription.Subscription", + ), ), ] diff --git a/timed/subscription/migrations/0002_auto_20170808_1729.py b/timed/subscription/migrations/0002_auto_20170808_1729.py index 35e1d95d4..150f79c21 100644 --- a/timed/subscription/migrations/0002_auto_20170808_1729.py +++ b/timed/subscription/migrations/0002_auto_20170808_1729.py @@ -8,14 +8,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('subscription', '0001_initial'), - ] + dependencies = [("subscription", "0001_initial")] operations = [ migrations.AlterField( - model_name='order', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='projects.Project'), - ), + model_name="order", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="orders", + to="projects.Project", + ), + ) ] diff --git a/timed/subscription/migrations/0003_auto_20170907_1151.py b/timed/subscription/migrations/0003_auto_20170907_1151.py index 933c13316..2d1b0aa44 100644 --- a/timed/subscription/migrations/0003_auto_20170907_1151.py +++ b/timed/subscription/migrations/0003_auto_20170907_1151.py @@ -7,18 +7,18 @@ SUBSCRIPTION_TO_BILLINGTYPE = { - 'DL-Budget': 'Engineering Budget', - 'SLA Störungsbehebung': 'SLA Incident Management', - 'Software Maintenance Abonnement': 'Software Maintenance', - 'SySupport-Premium': 'SSA Premium', - 'SySupport-Standard': 'SSA Standard' + "DL-Budget": "Engineering Budget", + "SLA Störungsbehebung": "SLA Incident Management", + "Software Maintenance Abonnement": "Software Maintenance", + "SySupport-Premium": "SSA Premium", + "SySupport-Standard": "SSA Standard", } def migrate_packages(apps, schema_editor): """Map package subscription to billing type.""" - Package = apps.get_model('subscription', 'Package') - BillingType = apps.get_model('projects', 'BillingType') + Package = apps.get_model("subscription", "Package") + BillingType = apps.get_model("projects", "BillingType") for subscription, billing_type in SUBSCRIPTION_TO_BILLINGTYPE.items(): pkgs = Package.objects.filter(subscription__name=subscription) @@ -33,34 +33,26 @@ def migrate_packages(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('projects', '0005_auto_20170907_0938'), - ('subscription', '0002_auto_20170808_1729'), + ("projects", "0005_auto_20170907_0938"), + ("subscription", "0002_auto_20170808_1729"), ] operations = [ - migrations.RemoveField( - model_name='subscriptionproject', - name='project', - ), - migrations.RemoveField( - model_name='subscriptionproject', - name='subscription', - ), + migrations.RemoveField(model_name="subscriptionproject", name="project"), + migrations.RemoveField(model_name="subscriptionproject", name="subscription"), migrations.AddField( - model_name='package', - name='billing_type', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='projects.BillingType', related_name='packages'), + model_name="package", + name="billing_type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="projects.BillingType", + related_name="packages", + ), preserve_default=False, ), migrations.RunPython(migrate_packages), - migrations.RemoveField( - model_name='package', - name='subscription', - ), - migrations.DeleteModel( - name='Subscription', - ), - migrations.DeleteModel( - name='SubscriptionProject', - ), + migrations.RemoveField(model_name="package", name="subscription"), + migrations.DeleteModel(name="Subscription"), + migrations.DeleteModel(name="SubscriptionProject"), ] diff --git a/timed/subscription/models.py b/timed/subscription/models.py index 6986a59ea..bc61ca0c9 100644 --- a/timed/subscription/models.py +++ b/timed/subscription/models.py @@ -8,34 +8,38 @@ class Package(models.Model): """Representing a subscription package.""" - billing_type = models.ForeignKey('projects.BillingType', - on_delete=models.CASCADE, - null=True, - related_name='packages') + billing_type = models.ForeignKey( + "projects.BillingType", + on_delete=models.CASCADE, + null=True, + related_name="packages", + ) """ This field has been added later so there might be old entries with null hence null=True. However blank=True is not set as it is required to set for new packages. """ - duration = models.DurationField() - price = MoneyField(max_digits=7, decimal_places=2, - default_currency='CHF') + duration = models.DurationField() + price = MoneyField(max_digits=7, decimal_places=2, default_currency="CHF") class Order(models.Model): """Order of customer for specific amount of hours.""" - project = models.ForeignKey('projects.Project', - on_delete=models.CASCADE, - related_name='orders') + project = models.ForeignKey( + "projects.Project", on_delete=models.CASCADE, related_name="orders" + ) duration = models.DurationField() ordered = models.DateTimeField(default=timezone.now) acknowledged = models.BooleanField(default=False) - confirmedby = models.ForeignKey(settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, blank=True, - related_name='orders_confirmed') + confirmedby = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="orders_confirmed", + ) class CustomerPassword(models.Model): @@ -46,7 +50,5 @@ class CustomerPassword(models.Model): once customer center will go live. """ - customer = models.OneToOneField('projects.Customer', - on_delete=models.CASCADE) - password = models.CharField(_('password'), max_length=128, - null=True, blank=True) + customer = models.OneToOneField("projects.Customer", on_delete=models.CASCADE) + password = models.CharField(_("password"), max_length=128, null=True, blank=True) diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index f63cb7772..ebd77dee7 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -2,8 +2,11 @@ from django.db.models import Sum from django.utils.duration import duration_string -from rest_framework_json_api.serializers import (CharField, ModelSerializer, - SerializerMethodField) +from rest_framework_json_api.serializers import ( + CharField, + ModelSerializer, + SerializerMethodField, +) from timed.projects.models import Project from timed.tracking.models import Report @@ -12,8 +15,8 @@ class SubscriptionProjectSerializer(ModelSerializer): - purchased_time = SerializerMethodField(source='get_purchased_time') - spent_time = SerializerMethodField(source='get_spent_time') + purchased_time = SerializerMethodField(source="get_purchased_time") + spent_time = SerializerMethodField(source="get_spent_time") def get_purchased_time(self, obj): """ @@ -22,8 +25,8 @@ def get_purchased_time(self, obj): Only acknowledged hours are included. """ orders = Order.objects.filter(project=obj, acknowledged=True) - data = orders.aggregate(purchased_time=Sum('duration')) - return duration_string(data['purchased_time'] or timedelta(0)) + data = orders.aggregate(purchased_time=Sum("duration")) + return duration_string(data["purchased_time"] or timedelta(0)) def get_spent_time(self, obj): """ @@ -31,27 +34,28 @@ def get_spent_time(self, obj): Reports which are not billable or are in review are excluded. """ - reports = Report.objects.filter(task__project=obj, not_billable=False, - review=False) - data = reports.aggregate(spent_time=Sum('duration')) - return duration_string(data['spent_time'] or timedelta()) + reports = Report.objects.filter( + task__project=obj, not_billable=False, review=False + ) + data = reports.aggregate(spent_time=Sum("duration")) + return duration_string(data["spent_time"] or timedelta()) included_serializers = { - 'billing_type': 'timed.projects.serializers.BillingTypeSerializer', - 'customer': 'timed.projects.serializers.CustomerSerializer', - 'orders': 'timed.subscription.serializers.OrderSerializer' + "billing_type": "timed.projects.serializers.BillingTypeSerializer", + "customer": "timed.projects.serializers.CustomerSerializer", + "orders": "timed.subscription.serializers.OrderSerializer", } class Meta: model = Project - resource_name = 'subscription-projects' + resource_name = "subscription-projects" fields = ( - 'name', - 'billing_type', - 'purchased_time', - 'spent_time', - 'customer', - 'orders' + "name", + "billing_type", + "purchased_time", + "spent_time", + "customer", + "orders", ) @@ -60,32 +64,22 @@ class PackageSerializer(ModelSerializer): """CharField needed as it includes currency.""" included_serializers = { - 'billing_type': 'timed.projects.serializers.BillingTypeSerializer' + "billing_type": "timed.projects.serializers.BillingTypeSerializer" } class Meta: model = Package - resource_name = 'subscription-packages' - fields = ( - 'duration', - 'price', - 'billing_type' - ) + resource_name = "subscription-packages" + fields = ("duration", "price", "billing_type") class OrderSerializer(ModelSerializer): included_serializers = { - 'project': ('timed.subscription.serializers' - '.SubscriptionProjectSerializer'), + "project": ("timed.subscription.serializers" ".SubscriptionProjectSerializer") } class Meta: model = Order - resource_name = 'subscription-orders' - fields = ( - 'duration', - 'acknowledged', - 'ordered', - 'project' - ) + resource_name = "subscription-orders" + fields = ("duration", "acknowledged", "ordered", "project") diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index d2b533080..efce8bbb3 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -7,22 +7,22 @@ def test_order_list(auth_client): factories.OrderFactory.create() - url = reverse('subscription-order-list') + url = reverse("subscription-order-list") res = auth_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() - assert len(json['data']) == 1 - assert json['data'][0]['relationships']['project']['data']['type'] == ( - 'subscription-projects' + assert len(json["data"]) == 1 + assert json["data"][0]["relationships"]["project"]["data"]["type"] == ( + "subscription-projects" ) def test_order_delete(auth_client): order = factories.OrderFactory.create() - url = reverse('subscription-order-detail', args=[order.id]) + url = reverse("subscription-order-detail", args=[order.id]) res = auth_client.delete(url) assert res.status_code == status.HTTP_204_NO_CONTENT @@ -32,7 +32,7 @@ def test_order_delete_confirmed(auth_client): """Deleting of confirmed order should not be possible.""" order = factories.OrderFactory(acknowledged=True) - url = reverse('subscription-order-detail', args=[order.id]) + url = reverse("subscription-order-detail", args=[order.id]) res = auth_client.delete(url) assert res.status_code == status.HTTP_403_FORBIDDEN @@ -42,7 +42,7 @@ def test_order_confirm_admin(admin_client): """Test that admin use may confirm order.""" order = factories.OrderFactory.create() - url = reverse('subscription-order-confirm', args=[order.id]) + url = reverse("subscription-order-confirm", args=[order.id]) res = admin_client.post(url) assert res.status_code == status.HTTP_204_NO_CONTENT @@ -56,7 +56,7 @@ def test_order_confirm_user(auth_client): """Test that default user may not confirm order.""" order = factories.OrderFactory.create() - url = reverse('subscription-order-confirm', args=[order.id]) + url = reverse("subscription-order-confirm", args=[order.id]) res = auth_client.post(url) assert res.status_code == status.HTTP_403_FORBIDDEN diff --git a/timed/subscription/tests/test_package.py b/timed/subscription/tests/test_package.py index fc633cc8e..60f5ccba7 100644 --- a/timed/subscription/tests/test_package.py +++ b/timed/subscription/tests/test_package.py @@ -1,37 +1,33 @@ from django.urls import reverse from rest_framework.status import HTTP_200_OK -from timed.projects.factories import (BillingTypeFactory, CustomerFactory, - ProjectFactory) +from timed.projects.factories import BillingTypeFactory, CustomerFactory, ProjectFactory from timed.subscription.factories import PackageFactory def test_subscription_package_list(auth_client): PackageFactory.create() - url = reverse('subscription-package-list') + url = reverse("subscription-package-list") res = auth_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json['data']) == 1 + assert len(json["data"]) == 1 def test_subscription_package_filter_customer(auth_client): customer = CustomerFactory.create() billing_type = BillingTypeFactory.create() package = PackageFactory.create(billing_type=billing_type) - ProjectFactory.create_batch( - 2, - billing_type=billing_type, customer=customer - ) + ProjectFactory.create_batch(2, billing_type=billing_type, customer=customer) - url = reverse('subscription-package-list') + url = reverse("subscription-package-list") - res = auth_client.get(url, data={'customer': customer.id}) + res = auth_client.get(url, data={"customer": customer.id}) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(package.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(package.id) diff --git a/timed/subscription/tests/test_subscription_project.py b/timed/subscription/tests/test_subscription_project.py index fe737987e..4c95a371d 100644 --- a/timed/subscription/tests/test_subscription_project.py +++ b/timed/subscription/tests/test_subscription_project.py @@ -3,8 +3,12 @@ from django.urls import reverse from rest_framework.status import HTTP_200_OK -from timed.projects.factories import (BillingTypeFactory, CustomerFactory, - ProjectFactory, TaskFactory) +from timed.projects.factories import ( + BillingTypeFactory, + CustomerFactory, + ProjectFactory, + TaskFactory, +) from timed.subscription.factories import OrderFactory, PackageFactory from timed.tracking.factories import ReportFactory @@ -13,9 +17,7 @@ def test_subscription_project_list(auth_client): customer = CustomerFactory.create() billing_type = BillingTypeFactory() project = ProjectFactory.create( - billing_type=billing_type, - customer=customer, - customer_visible=True + billing_type=billing_type, customer=customer, customer_visible=True ) PackageFactory.create_batch(2, billing_type=billing_type) # create spent hours @@ -24,60 +26,41 @@ def test_subscription_project_list(auth_client): ReportFactory.create(task=task, duration=timedelta(hours=2)) ReportFactory.create(task=task, duration=timedelta(hours=3)) # not billable reports should not be included in spent hours - ReportFactory.create(not_billable=True, task=task, - duration=timedelta(hours=4)) + ReportFactory.create(not_billable=True, task=task, duration=timedelta(hours=4)) # project of same customer but without customer_visible set # should not appear ProjectFactory.create(customer=customer) # create purchased time - OrderFactory.create( - project=project, - acknowledged=True, - duration=timedelta(hours=2) - ) - OrderFactory.create( - project=project, - acknowledged=True, - duration=timedelta(hours=4) - ) + OrderFactory.create(project=project, acknowledged=True, duration=timedelta(hours=2)) + OrderFactory.create(project=project, acknowledged=True, duration=timedelta(hours=4)) # report on different project should not be included in spent time ReportFactory.create(duration=timedelta(hours=2)) # not acknowledged order should not be included in purchased time - OrderFactory.create( - project=project, - duration=timedelta(hours=2) - ) + OrderFactory.create(project=project, duration=timedelta(hours=2)) - url = reverse('subscription-project-list') + url = reverse("subscription-project-list") - res = auth_client.get( - url, - data={'customer': customer.id, - 'ordering': 'id'} - ) + res = auth_client.get(url, data={"customer": customer.id, "ordering": "id"}) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(project.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(project.id) - attrs = json['data'][0]['attributes'] - assert attrs['spent-time'] == '05:00:00' - assert attrs['purchased-time'] == '06:00:00' + attrs = json["data"][0]["attributes"] + assert attrs["spent-time"] == "05:00:00" + assert attrs["purchased-time"] == "06:00:00" def test_subscription_project_detail(auth_client): billing_type = BillingTypeFactory() - project = ProjectFactory.create( - billing_type=billing_type, - customer_visible=True - ) + project = ProjectFactory.create(billing_type=billing_type, customer_visible=True) PackageFactory.create_batch(2, billing_type=billing_type) - url = reverse('subscription-project-detail', args=[project.id]) + url = reverse("subscription-project-detail", args=[project.id]) res = auth_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() - assert json['data']['id'] == str(project.id) + assert json["data"]["id"] == str(project.id) diff --git a/timed/subscription/urls.py b/timed/subscription/urls.py index 3c2621e79..c721b0713 100644 --- a/timed/subscription/urls.py +++ b/timed/subscription/urls.py @@ -5,10 +5,10 @@ r = DefaultRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'subscription-projects', views.SubscriptionProjectViewSet, - 'subscription-project') -r.register(r'subscription-packages', views.PackageViewSet, - 'subscription-package') -r.register(r'subscription-orders', views.OrderViewSet, 'subscription-order') +r.register( + r"subscription-projects", views.SubscriptionProjectViewSet, "subscription-project" +) +r.register(r"subscription-packages", views.PackageViewSet, "subscription-package") +r.register(r"subscription-orders", views.OrderViewSet, "subscription-order") urlpatterns = r.urls diff --git a/timed/subscription/views.py b/timed/subscription/views.py index d3a2c9bf0..a34e071e0 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,5 +1,12 @@ -from rest_framework import (decorators, exceptions, mixins, permissions, - response, status, viewsets) +from rest_framework import ( + decorators, + exceptions, + mixins, + permissions, + response, + status, + viewsets, +) from timed.projects.filters import ProjectFilterSet from timed.projects.models import Project @@ -17,16 +24,10 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.SubscriptionProjectSerializer filterset_class = ProjectFilterSet - ordering_fields = ( - 'name', - 'id' - ) + ordering_fields = ("name", "id") def get_queryset(self): - return Project.objects.filter( - archived=False, - customer_visible=True - ) + return Project.objects.filter(archived=False, customer_visible=True) class PackageViewSet(viewsets.ReadOnlyModelViewSet): @@ -34,26 +35,23 @@ class PackageViewSet(viewsets.ReadOnlyModelViewSet): filterset_class = filters.PackageFilter def get_queryset(self): - return models.Package.objects.select_related( - 'billing_type' - ) + return models.Package.objects.select_related("billing_type") -class OrderViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): +class OrderViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): serializer_class = serializers.OrderSerializer filterset_class = filters.OrderFilter @decorators.action( detail=True, - methods=['post'], - permission_classes=[ - permissions.IsAuthenticated, - permissions.IsAdminUser - ] + methods=["post"], + permission_classes=[permissions.IsAuthenticated, permissions.IsAdminUser], ) def confirm(self, request, pk=None): """ @@ -69,9 +67,7 @@ def confirm(self, request, pk=None): return response.Response(status=status.HTTP_204_NO_CONTENT) def get_queryset(self): - return models.Order.objects.select_related( - 'project' - ) + return models.Order.objects.select_related("project") def perform_destroy(self, instance): if instance.acknowledged: diff --git a/timed/tests/client.py b/timed/tests/client.py index 5a47eb005..9c0bcc2a6 100644 --- a/timed/tests/client.py +++ b/timed/tests/client.py @@ -15,7 +15,7 @@ def __init__(self, *args, **kwargs): """Initialize the API client.""" super().__init__(*args, **kwargs) - self._content_type = 'application/vnd.api+json' + self._content_type = "application/vnd.api+json" def _parse_data(self, data): return json.dumps(data) if data else data @@ -27,10 +27,7 @@ def get(self, path, data=None, **kwargs): :param dict data: The data of the request """ return super().get( - path=path, - data=data, - content_type=self._content_type, - **kwargs + path=path, data=data, content_type=self._content_type, **kwargs ) def post(self, path, data=None, **kwargs): @@ -80,23 +77,19 @@ def login(self, username, password): :raises: exceptions.AuthenticationFailed """ data = { - 'data': { - 'attributes': { - 'username': username, - 'password': password - }, - 'type': 'obtain-json-web-tokens', + "data": { + "attributes": {"username": username, "password": password}, + "type": "obtain-json-web-tokens", } } - response = self.post(reverse('login'), data) + response = self.post(reverse("login"), data) if response.status_code != status.HTTP_200_OK: raise exceptions.AuthenticationFailed() self.credentials( - HTTP_AUTHORIZATION='{0} {1}'.format( - api_settings.JWT_AUTH_HEADER_PREFIX, - response.data['token'] + HTTP_AUTHORIZATION="{0} {1}".format( + api_settings.JWT_AUTH_HEADER_PREFIX, response.data["token"] ) ) diff --git a/timed/tests/test_client.py b/timed/tests/test_client.py index 7cbd7a823..21ab16f53 100644 --- a/timed/tests/test_client.py +++ b/timed/tests/test_client.py @@ -7,17 +7,14 @@ def test_client_login(db): get_user_model().objects.create_user( - username='user', - password='123qweasd', - first_name='Test', - last_name='User', + username="user", password="123qweasd", first_name="Test", last_name="User" ) client = JSONAPIClient() - client.login('user', '123qweasd') + client.login("user", "123qweasd") def test_client_login_fails(db): client = JSONAPIClient() with pytest.raises(exceptions.AuthenticationFailed): - client.login('someuser', 'invalidpw') + client.login("someuser", "invalidpw") diff --git a/timed/tests/test_settings.py b/timed/tests/test_settings.py index 79881d000..33149b283 100644 --- a/timed/tests/test_settings.py +++ b/timed/tests/test_settings.py @@ -5,11 +5,11 @@ def test_admins(): - assert settings.parse_admins(['Test Example ']) == [ - ('Test Example', 'test@example.com'), + assert settings.parse_admins(["Test Example "]) == [ + ("Test Example", "test@example.com") ] def test_invalid_admins(monkeypatch): with pytest.raises(environ.ImproperlyConfigured): - settings.parse_admins(['Test Example = employment.worktime_per_day: # prevent negative duration in case user already # reported more time than worktime per day @@ -181,4 +182,4 @@ def calculate_duration(self, employment): class Meta: """Meta informations for the absence model.""" - unique_together = ('date', 'user',) + unique_together = ("date", "user") diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index c37305f47..b761ba249 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -7,9 +7,12 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework_json_api import relations, serializers from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import (ModelSerializer, Serializer, - SerializerMethodField, - ValidationError) +from rest_framework_json_api.serializers import ( + ModelSerializer, + Serializer, + SerializerMethodField, + ValidationError, +) from timed.employment.models import AbsenceType, Employment, PublicHoliday from timed.employment.relations import CurrentUserResourceRelatedField @@ -21,11 +24,11 @@ class ActivitySerializer(ModelSerializer): """Activity serializer.""" - user = CurrentUserResourceRelatedField() + user = CurrentUserResourceRelatedField() included_serializers = { - 'task': 'timed.projects.serializers.TaskSerializer', - 'user': 'timed.employment.serializers.UserSerializer', + "task": "timed.projects.serializers.TaskSerializer", + "user": "timed.employment.serializers.UserSerializer", } def validate(self, data): @@ -35,15 +38,13 @@ def validate(self, data): which doesn't end before it started. """ instance = self.instance - from_time = data.get('from_time', instance and instance.from_time) - to_time = data.get('to_time', instance and instance.to_time) - user = instance and instance.user or data['user'] + from_time = data.get("from_time", instance and instance.from_time) + to_time = data.get("to_time", instance and instance.to_time) + user = instance and instance.user or data["user"] def validate_running_activity(): if activity.filter(to_time__isnull=True).exists(): - raise ValidationError( - _('A user can only have one active activity') - ) + raise ValidationError(_("A user can only have one active activity")) # validate that there is only one active activity activity = models.Activity.objects.filter(user=user) @@ -56,17 +57,15 @@ def validate_running_activity(): # validate that to is not before from if to_time is not None and to_time < from_time: - raise ValidationError( - _('An activity block may not end before it starts.') - ) + raise ValidationError(_("An activity block may not end before it starts.")) return data class Meta: """Meta information for the activity serializer.""" - model = models.Activity - fields = '__all__' + model = models.Activity + fields = "__all__" class AttendanceSerializer(ModelSerializer): @@ -74,94 +73,85 @@ class AttendanceSerializer(ModelSerializer): user = CurrentUserResourceRelatedField() - included_serializers = { - 'user': 'timed.employment.serializers.UserSerializer', - } + included_serializers = {"user": "timed.employment.serializers.UserSerializer"} class Meta: """Meta information for the attendance serializer.""" - model = models.Attendance - fields = [ - 'date', - 'from_time', - 'to_time', - 'user', - ] + model = models.Attendance + fields = ["date", "from_time", "to_time", "user"] class ReportSerializer(TotalTimeRootMetaMixin, ModelSerializer): """Report serializer.""" - task = ResourceRelatedField(queryset=Task.objects.all()) - activity = ResourceRelatedField(queryset=models.Activity.objects.all(), - allow_null=True, - required=False) - user = CurrentUserResourceRelatedField() - verified_by = ResourceRelatedField(queryset=get_user_model().objects, - required=False, allow_null=True) + task = ResourceRelatedField(queryset=Task.objects.all()) + activity = ResourceRelatedField( + queryset=models.Activity.objects.all(), allow_null=True, required=False + ) + user = CurrentUserResourceRelatedField() + verified_by = ResourceRelatedField( + queryset=get_user_model().objects, required=False, allow_null=True + ) included_serializers = { - 'task': 'timed.projects.serializers.TaskSerializer', - 'user': 'timed.employment.serializers.UserSerializer', - 'verified_by': 'timed.employment.serializers.UserSerializer' + "task": "timed.projects.serializers.TaskSerializer", + "user": "timed.employment.serializers.UserSerializer", + "verified_by": "timed.employment.serializers.UserSerializer", } def validate_date(self, value): """Only owner is allowed to change date.""" if self.instance is not None: - user = self.context['request'].user + user = self.context["request"].user owner = self.instance.user if self.instance.date != value and user != owner: - raise ValidationError(_('Only owner may change date')) + raise ValidationError(_("Only owner may change date")) return value def validate_duration(self, value): """Only owner is allowed to change duration.""" if self.instance is not None: - user = self.context['request'].user + user = self.context["request"].user owner = self.instance.user if self.instance.duration != value and user != owner: - raise ValidationError(_('Only owner may change duration')) + raise ValidationError(_("Only owner may change duration")) return value def validate(self, data): """Validate that verified by is only set by reviewer or superuser.""" - user = self.context['request'].user + user = self.context["request"].user current_verified_by = self.instance and self.instance.verified_by - new_verified_by = data.get('verified_by') - task = data.get('task') or self.instance.task + new_verified_by = data.get("verified_by") + task = data.get("task") or self.instance.task if new_verified_by != current_verified_by: is_reviewer = ( - user.is_superuser or - task.project.reviewers.filter(id=user.id).exists() + user.is_superuser or task.project.reviewers.filter(id=user.id).exists() ) if not is_reviewer: - raise ValidationError(_('Only reviewer may verify reports.')) + raise ValidationError(_("Only reviewer may verify reports.")) if new_verified_by is not None and new_verified_by != user: - raise ValidationError( - _('You may only verifiy with your own user') - ) + raise ValidationError(_("You may only verifiy with your own user")) return data class Meta: - model = models.Report + model = models.Report fields = [ - 'comment', - 'date', - 'duration', - 'review', - 'not_billable', - 'task', - 'activity', - 'user', - 'verified_by', + "comment", + "date", + "duration", + "review", + "not_billable", + "task", + "activity", + "user", + "verified_by", ] @@ -177,7 +167,7 @@ class ReportBulkSerializer(Serializer): verified = serializers.NullBooleanField(required=False) class Meta: - resource_name = 'report-bulks' + resource_name = "report-bulks" class ReportIntersectionSerializer(Serializer): @@ -192,19 +182,13 @@ class ReportIntersectionSerializer(Serializer): """ customer = relations.SerializerMethodResourceRelatedField( - source='get_customer', - model=Customer, - read_only=True + source="get_customer", model=Customer, read_only=True ) project = relations.SerializerMethodResourceRelatedField( - source='get_project', - model=Project, - read_only=True + source="get_project", model=Project, read_only=True ) task = relations.SerializerMethodResourceRelatedField( - source='get_task', - model=Task, - read_only=True + source="get_task", model=Task, read_only=True ) comment = SerializerMethodField() review = SerializerMethodField() @@ -218,7 +202,7 @@ def _intersection(self, instance, field, model=None): otherwise None """ value = None - queryset = instance['queryset'] + queryset = instance["queryset"] values = queryset.values(field).distinct() if values.count() == 1: value = values.first()[field] @@ -228,71 +212,65 @@ def _intersection(self, instance, field, model=None): return value def get_customer(self, instance): - return self._intersection( - instance, 'task__project__customer', Customer - ) + return self._intersection(instance, "task__project__customer", Customer) def get_project(self, instance): - return self._intersection(instance, 'task__project', Project) + return self._intersection(instance, "task__project", Project) def get_task(self, instance): - return self._intersection(instance, 'task', Task) + return self._intersection(instance, "task", Task) def get_comment(self, instance): - return self._intersection(instance, 'comment') + return self._intersection(instance, "comment") def get_review(self, instance): - return self._intersection(instance, 'review') + return self._intersection(instance, "review") def get_not_billable(self, instance): - return self._intersection(instance, 'not_billable') + return self._intersection(instance, "not_billable") def get_verified(self, instance): - queryset = instance['queryset'] + queryset = instance["queryset"] queryset = queryset.annotate( verified=Case( When(verified_by_id__isnull=True, then=False), default=True, - output_field=BooleanField() + output_field=BooleanField(), ) ) - instance['queryset'] = queryset - return self._intersection(instance, 'verified') + instance["queryset"] = queryset + return self._intersection(instance, "verified") def get_root_meta(self, resource, many): """Add number of results to meta.""" - queryset = self.instance['queryset'] - return { - 'count': queryset.count() - } + queryset = self.instance["queryset"] + return {"count": queryset.count()} included_serializers = { - 'customer': 'timed.projects.serializers.CustomerSerializer', - 'project': 'timed.projects.serializers.ProjectSerializer', - 'task': 'timed.projects.serializers.TaskSerializer', + "customer": "timed.projects.serializers.CustomerSerializer", + "project": "timed.projects.serializers.ProjectSerializer", + "task": "timed.projects.serializers.TaskSerializer", } class Meta: - resource_name = 'report-intersections' + resource_name = "report-intersections" class AbsenceSerializer(ModelSerializer): """Absence serializer.""" - duration = SerializerMethodField(source='get_duration') - type = ResourceRelatedField(queryset=AbsenceType.objects.all()) - user = CurrentUserResourceRelatedField() + duration = SerializerMethodField(source="get_duration") + type = ResourceRelatedField(queryset=AbsenceType.objects.all()) + user = CurrentUserResourceRelatedField() included_serializers = { - 'user': 'timed.employment.serializers.UserSerializer', - 'type': 'timed.employment.serializers.AbsenceTypeSerializer', + "user": "timed.employment.serializers.UserSerializer", + "type": "timed.employment.serializers.AbsenceTypeSerializer", } def get_duration(self, instance): try: - employment = Employment.objects.get_at( - instance.user, instance.date - ) + employment = Employment.objects.get_at(instance.user, instance.date) except Employment.DoesNotExist: # absence is invalid if no employment exists on absence date return duration_string(timedelta()) @@ -302,20 +280,20 @@ def get_duration(self, instance): def validate_date(self, value): """Only owner is allowed to change date.""" if self.instance is not None: - user = self.context['request'].user + user = self.context["request"].user owner = self.instance.user if self.instance.date != value and user != owner: - raise ValidationError(_('Only owner may change date')) + raise ValidationError(_("Only owner may change date")) return value def validate_type(self, value): """Only owner is allowed to change type.""" if self.instance is not None: - user = self.context['request'].user + user = self.context["request"].user owner = self.instance.user if self.instance.date != value and user != owner: - raise ValidationError(_('Only owner may change absence type')) + raise ValidationError(_("Only owner may change absence type")) return value @@ -328,41 +306,27 @@ def validate(self, data): :rtype: dict """ instance = self.instance - user = data.get('user', instance and instance.user) + user = data.get("user", instance and instance.user) try: - location = Employment.objects.get_at( - user, - data.get('date') - ).location + location = Employment.objects.get_at(user, data.get("date")).location except Employment.DoesNotExist: raise ValidationError( - _('You can\'t create an absence on an unemployed day.') + _("You can't create an absence on an unemployed day.") ) if PublicHoliday.objects.filter( - location_id=location.id, - date=data.get('date') + location_id=location.id, date=data.get("date") ).exists(): - raise ValidationError( - _('You can\'t create an absence on a public holiday') - ) + raise ValidationError(_("You can't create an absence on a public holiday")) workdays = [int(day) for day in location.workdays] - if data.get('date').isoweekday() not in workdays: - raise ValidationError( - _('You can\'t create an absence on a weekend') - ) + if data.get("date").isoweekday() not in workdays: + raise ValidationError(_("You can't create an absence on a weekend")) return data class Meta: """Meta information for the absence serializer.""" - model = models.Absence - fields = [ - 'comment', - 'date', - 'duration', - 'type', - 'user', - ] + model = models.Absence + fields = ["comment", "date", "duration", "type", "user"] diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index af4cd8547..3ed223ec1 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -3,8 +3,12 @@ from django.urls import reverse from rest_framework import status -from timed.employment.factories import (AbsenceTypeFactory, EmploymentFactory, - PublicHolidayFactory, UserFactory) +from timed.employment.factories import ( + AbsenceTypeFactory, + EmploymentFactory, + PublicHolidayFactory, + UserFactory, +) from timed.tracking.factories import AbsenceFactory, ReportFactory @@ -13,34 +17,32 @@ def test_absence_list_authenticated(auth_client): # overlapping absence with public holidays need to be hidden overlap_absence = AbsenceFactory.create( - user=auth_client.user, date=datetime.date(2018, 1, 1)) - employment = EmploymentFactory.create( - user=overlap_absence.user, - start_date=datetime.date(2017, 12, 31) + user=auth_client.user, date=datetime.date(2018, 1, 1) ) - PublicHolidayFactory.create( - date=overlap_absence.date, location=employment.location + employment = EmploymentFactory.create( + user=overlap_absence.user, start_date=datetime.date(2017, 12, 31) ) - url = reverse('absence-list') + PublicHolidayFactory.create(date=overlap_absence.date, location=employment.location) + url = reverse("absence-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(absence.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(absence.id) def test_absence_list_superuser(superadmin_client): AbsenceFactory.create_batch(2) - url = reverse('absence-list') + url = reverse("absence-list") response = superadmin_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 2 + assert len(json["data"]) == 2 def test_absence_list_supervisor(auth_client): @@ -50,63 +52,52 @@ def test_absence_list_supervisor(auth_client): AbsenceFactory.create(user=auth_client.user) AbsenceFactory.create(user=user) - url = reverse('absence-list') + url = reverse("absence-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 2 + assert len(json["data"]) == 2 def test_absence_detail(auth_client): absence = AbsenceFactory.create(user=auth_client.user) - url = reverse('absence-detail', args=[ - absence.id - ]) + url = reverse("absence-detail", args=[absence.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert json['data']['id'] == str(absence.id) + assert json["data"]["id"] == str(absence.id) def test_absence_create(auth_client): user = auth_client.user date = datetime.date(2017, 5, 4) EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) type = AbsenceTypeFactory.create() data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') + "data": { + "type": "absences", + "id": None, + "attributes": {"date": date.strftime("%Y-%m-%d")}, + "relationships": { + "type": {"data": {"type": "absence-types", "id": type.id}} }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } - } - } } } - url = reverse('absence-list') + url = reverse("absence-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() - assert json['data']['relationships']['user']['data']['id'] == ( + assert json["data"]["relationships"]["user"]["data"]["id"] == ( str(auth_client.user.id) ) @@ -115,63 +106,47 @@ def test_absence_update_owner(auth_client): user = auth_client.user date = datetime.date(2017, 5, 3) absence = AbsenceFactory.create( - user=auth_client.user, - date=datetime.date(2016, 5, 3) + user=auth_client.user, date=datetime.date(2016, 5, 3) ) EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) data = { - 'data': { - 'type': 'absences', - 'id': absence.id, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') - } + "data": { + "type": "absences", + "id": absence.id, + "attributes": {"date": date.strftime("%Y-%m-%d")}, } } - url = reverse('absence-detail', args=[ - absence.id - ]) + url = reverse("absence-detail", args=[absence.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() - assert json['data']['attributes']['date'] == '2017-05-03' + assert json["data"]["attributes"]["date"] == "2017-05-03" def test_absence_update_superadmin_date(superadmin_client): """Test that superadmin may not change date of absence.""" user = UserFactory.create() date = datetime.date(2017, 5, 3) - absence = AbsenceFactory.create( - user=user, - date=datetime.date(2016, 5, 3) - ) + absence = AbsenceFactory.create(user=user, date=datetime.date(2016, 5, 3)) EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) data = { - 'data': { - 'type': 'absences', - 'id': absence.id, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') - } + "data": { + "type": "absences", + "id": absence.id, + "attributes": {"date": date.strftime("%Y-%m-%d")}, } } - url = reverse('absence-detail', args=[ - absence.id - ]) + url = reverse("absence-detail", args=[absence.id]) response = superadmin_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -182,34 +157,22 @@ def test_absence_update_superadmin_type(superadmin_client): user = UserFactory.create() date = datetime.date(2017, 5, 3) type = AbsenceTypeFactory.create() - absence = AbsenceFactory.create( - user=user, - date=datetime.date(2016, 5, 3) - ) + absence = AbsenceFactory.create(user=user, date=datetime.date(2016, 5, 3)) EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) data = { - 'data': { - 'type': 'absences', - 'id': absence.id, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } - } - } + "data": { + "type": "absences", + "id": absence.id, + "relationships": { + "type": {"data": {"type": "absence-types", "id": type.id}} + }, } } - url = reverse('absence-detail', args=[ - absence.id - ]) + url = reverse("absence-detail", args=[absence.id]) response = superadmin_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -218,7 +181,7 @@ def test_absence_update_superadmin_type(superadmin_client): def test_absence_delete_owner(auth_client): absence = AbsenceFactory.create(user=auth_client.user) - url = reverse('absence-detail', args=[absence.id]) + url = reverse("absence-detail", args=[absence.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -229,7 +192,7 @@ def test_absence_delete_superuser(superadmin_client): user = UserFactory.create() absence = AbsenceFactory.create(user=user) - url = reverse('absence-detail', args=[absence.id]) + url = reverse("absence-detail", args=[absence.id]) response = superadmin_client.delete(url) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -240,43 +203,30 @@ def test_absence_fill_worktime(auth_client): date = datetime.date(2017, 5, 10) user = auth_client.user EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) type = AbsenceTypeFactory.create(fill_worktime=True) - ReportFactory.create( - user=user, - date=date, - duration=datetime.timedelta(hours=5) - ) + ReportFactory.create(user=user, date=date, duration=datetime.timedelta(hours=5)) data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') + "data": { + "type": "absences", + "id": None, + "attributes": {"date": date.strftime("%Y-%m-%d")}, + "relationships": { + "type": {"data": {"type": "absence-types", "id": type.id}} }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } - } - } } } - url = reverse('absence-list') + url = reverse("absence-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() - assert json['data']['attributes']['duration'] == '03:00:00' + assert json["data"]["attributes"]["duration"] == "03:00:00" def test_absence_fill_worktime_reported_time_to_long(auth_client): @@ -288,43 +238,32 @@ def test_absence_fill_worktime_reported_time_to_long(auth_client): date = datetime.date(2017, 5, 10) user = auth_client.user EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) type = AbsenceTypeFactory.create(fill_worktime=True) ReportFactory.create( - user=user, - date=date, - duration=datetime.timedelta(hours=8, minutes=30) + user=user, date=date, duration=datetime.timedelta(hours=8, minutes=30) ) data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') + "data": { + "type": "absences", + "id": None, + "attributes": {"date": date.strftime("%Y-%m-%d")}, + "relationships": { + "type": {"data": {"type": "absence-types", "id": type.id}} }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } - } - } } } - url = reverse('absence-list') + url = reverse("absence-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() - assert json['data']['attributes']['duration'] == '00:00:00' + assert json["data"]["attributes"]["duration"] == "00:00:00" def test_absence_weekend(auth_client): @@ -333,30 +272,21 @@ def test_absence_weekend(auth_client): user = auth_client.user type = AbsenceTypeFactory.create() EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') + "data": { + "type": "absences", + "id": None, + "attributes": {"date": date.strftime("%Y-%m-%d")}, + "relationships": { + "type": {"data": {"type": "absence-types", "id": type.id}} }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } - } - } } } - url = reverse('absence-list') + url = reverse("absence-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -368,31 +298,22 @@ def test_absence_public_holiday(auth_client): user = auth_client.user type = AbsenceTypeFactory.create() employment = EmploymentFactory.create( - user=user, - start_date=date, - worktime_per_day=datetime.timedelta(hours=8) + user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) PublicHolidayFactory.create(location=employment.location, date=date) data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': date.strftime('%Y-%m-%d') + "data": { + "type": "absences", + "id": None, + "attributes": {"date": date.strftime("%Y-%m-%d")}, + "relationships": { + "type": {"data": {"type": "absence-types", "id": type.id}} }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } - } - } } } - url = reverse('absence-list') + url = reverse("absence-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -403,24 +324,17 @@ def test_absence_create_unemployed(auth_client): type = AbsenceTypeFactory.create() data = { - 'data': { - 'type': 'absences', - 'id': None, - 'attributes': { - 'date': '2017-05-16' + "data": { + "type": "absences", + "id": None, + "attributes": {"date": "2017-05-16"}, + "relationships": { + "type": {"data": {"type": "absence-types", "id": type.id}} }, - 'relationships': { - 'type': { - 'data': { - 'type': 'absence-types', - 'id': type.id - } - } - } } } - url = reverse('absence-list') + url = reverse("absence-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -430,10 +344,10 @@ def test_absence_detail_unemployed(auth_client): """Test creation of absence fails on unemployed day.""" absence = AbsenceFactory.create(user=auth_client.user) - url = reverse('absence-detail', args=[absence.id]) + url = reverse("absence-detail", args=[absence.id]) res = auth_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() - assert json['data']['attributes']['duration'] == '00:00:00' + assert json["data"]["attributes"]["duration"] == "00:00:00" diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 950a1dea0..6cf24892b 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -9,22 +9,20 @@ def test_activity_list(auth_client): activity = ActivityFactory.create(user=auth_client.user) - url = reverse('activity-list') + url = reverse("activity-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(activity.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(activity.id) def test_activity_detail(auth_client): activity = ActivityFactory.create(user=auth_client.user) - url = reverse('activity-detail', args=[ - activity.id - ]) + url = reverse("activity-detail", args=[activity.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -36,70 +34,53 @@ def test_activity_create(auth_client): task = TaskFactory.create() data = { - 'data': { - 'type': 'activities', - 'id': None, - 'attributes': { - 'from-time': '08:00', - 'date': '2017-01-01', - 'comment': 'Test activity' + "data": { + "type": "activities", + "id": None, + "attributes": { + "from-time": "08:00", + "date": "2017-01-01", + "comment": "Test activity", }, - 'relationships': { - 'task': { - 'data': { - 'type': 'tasks', - 'id': task.id - } - } - } + "relationships": {"task": {"data": {"type": "tasks", "id": task.id}}}, } } - url = reverse('activity-list') + url = reverse("activity-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() - assert ( - int(json['data']['relationships']['user']['data']['id']) == - int(user.id) - ) + assert int(json["data"]["relationships"]["user"]["data"]["id"]) == int(user.id) def test_activity_update(auth_client): activity = ActivityFactory.create(user=auth_client.user) data = { - 'data': { - 'type': 'activities', - 'id': activity.id, - 'attributes': { - 'comment': 'Test activity 2' - } + "data": { + "type": "activities", + "id": activity.id, + "attributes": {"comment": "Test activity 2"}, } } - url = reverse('activity-detail', args=[ - activity.id - ]) + url = reverse("activity-detail", args=[activity.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() assert ( - json['data']['attributes']['comment'] == - data['data']['attributes']['comment'] + json["data"]["attributes"]["comment"] == data["data"]["attributes"]["comment"] ) def test_activity_delete(auth_client): activity = ActivityFactory.create(user=auth_client.user) - url = reverse('activity-detail', args=[ - activity.id - ]) + url = reverse("activity-detail", args=[activity.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -110,13 +91,13 @@ def test_activity_list_filter_active(auth_client): ActivityFactory.create(user=user) activity = ActivityFactory.create(user=user, to_time=None) - url = reverse('activity-list') + url = reverse("activity-list") - response = auth_client.get(url, data={'active': 'true'}) + response = auth_client.get(url, data={"active": "true"}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(activity.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(activity.id) def test_activity_list_filter_day(auth_client): @@ -125,40 +106,36 @@ def test_activity_list_filter_day(auth_client): ActivityFactory.create(date=day - timedelta(days=1), user=user) activity = ActivityFactory.create(date=day, user=user) - url = reverse('activity-list') - response = auth_client.get(url, data={'day': day.strftime('%Y-%m-%d')}) + url = reverse("activity-list") + response = auth_client.get(url, data={"day": day.strftime("%Y-%m-%d")}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(activity.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(activity.id) def test_activity_create_no_task(auth_client): """Should create a new activity without a task.""" data = { - 'data': { - 'type': 'activities', - 'id': None, - 'attributes': { - 'from-time': '08:00', - 'date': '2017-01-01', - 'comment': 'Test activity' + "data": { + "type": "activities", + "id": None, + "attributes": { + "from-time": "08:00", + "date": "2017-01-01", + "comment": "Test activity", }, - 'relationships': { - 'task': { - 'data': None - } - } + "relationships": {"task": {"data": None}}, } } - url = reverse('activity-list') + url = reverse("activity-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() - assert json['data']['relationships']['task']['data'] is None + assert json["data"]["relationships"]["task"]["data"] is None def test_activity_active_unique(auth_client): @@ -166,52 +143,48 @@ def test_activity_active_unique(auth_client): ActivityFactory.create(user=auth_client.user, to_time=None) data = { - 'data': { - 'type': 'activities', - 'id': None, - 'attributes': { - 'from-time': '08:00', - 'date': '2017-01-01', - 'comment': 'Test activity' - } + "data": { + "type": "activities", + "id": None, + "attributes": { + "from-time": "08:00", + "date": "2017-01-01", + "comment": "Test activity", + }, } } - url = reverse('activity-list') + url = reverse("activity-list") res = auth_client.post(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST json = res.json() - assert json['errors'][0]['detail'] == ( - 'A user can only have one active activity' - ) + assert json["errors"][0]["detail"] == ("A user can only have one active activity") def test_activity_to_before_from(auth_client): """Test that to is not before from.""" - activity = ActivityFactory.create(user=auth_client.user, - from_time=time(7, 30), - to_time=None) + activity = ActivityFactory.create( + user=auth_client.user, from_time=time(7, 30), to_time=None + ) data = { - 'data': { - 'type': 'activities', - 'id': activity.id, - 'attributes': { - 'to-time': '07:00', - } + "data": { + "type": "activities", + "id": activity.id, + "attributes": {"to-time": "07:00"}, } } - url = reverse('activity-detail', args=[activity.id]) + url = reverse("activity-detail", args=[activity.id]) res = auth_client.patch(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST json = res.json() - assert json['errors'][0]['detail'] == ( - 'An activity block may not end before it starts.' + assert json["errors"][0]["detail"] == ( + "An activity block may not end before it starts." ) @@ -220,16 +193,14 @@ def test_activity_not_editable(auth_client): activity = ActivityFactory.create(user=auth_client.user, transferred=True) data = { - 'data': { - 'type': 'activities', - 'id': activity.id, - 'attributes': { - 'comment': 'Changed Comment', - } + "data": { + "type": "activities", + "id": activity.id, + "attributes": {"comment": "Changed Comment"}, } } - url = reverse('activity-detail', args=[activity.id]) + url = reverse("activity-detail", args=[activity.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -238,9 +209,7 @@ def test_activity_retrievable_not_editable(auth_client): """Test that transferred activities are still retrievable.""" activity = ActivityFactory.create(user=auth_client.user, transferred=True) - url = reverse('activity-detail', args=[ - activity.id - ]) + url = reverse("activity-detail", args=[activity.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -250,23 +219,19 @@ def test_activity_active_update(auth_client): activity = ActivityFactory.create(user=auth_client.user, to_time=None) data = { - 'data': { - 'type': 'activities', - 'id': activity.id, - 'attributes': { - 'from-time': '08:00', - 'comment': 'Changed Comment', - } + "data": { + "type": "activities", + "id": activity.id, + "attributes": {"from-time": "08:00", "comment": "Changed Comment"}, } } - url = reverse('activity-detail', args=[activity.id]) + url = reverse("activity-detail", args=[activity.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() assert ( - json['data']['attributes']['comment'] == - data['data']['attributes']['comment'] + json["data"]["attributes"]["comment"] == data["data"]["attributes"]["comment"] ) @@ -275,16 +240,14 @@ def test_activity_set_to_time_none(auth_client): ActivityFactory.create(user=auth_client.user, to_time=None) data = { - 'data': { - 'type': 'activities', - 'id': activity.id, - 'attributes': { - 'to-time': None, - } + "data": { + "type": "activities", + "id": activity.id, + "attributes": {"to-time": None}, } } - url = reverse('activity-detail', args=[activity.id]) + url = reverse("activity-detail", args=[activity.id]) res = auth_client.patch(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index 3a63a6fe5..4779227da 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -8,19 +8,19 @@ def test_attendance_list(auth_client): AttendanceFactory.create() attendance = AttendanceFactory.create(user=auth_client.user) - url = reverse('attendance-list') + url = reverse("attendance-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(attendance.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(attendance.id) def test_attendance_detail(auth_client): attendance = AttendanceFactory.create(user=auth_client.user) - url = reverse('attendance-detail', args=[attendance.id]) + url = reverse("attendance-detail", args=[attendance.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -29,65 +29,55 @@ def test_attendance_create(auth_client): """Should create a new attendance and automatically set the user.""" user = auth_client.user data = { - 'data': { - 'type': 'attendances', - 'id': None, - 'attributes': { - 'date': '2017-01-01', - 'from-time': '08:00', - 'to-time': '10:00' - } + "data": { + "type": "attendances", + "id": None, + "attributes": { + "date": "2017-01-01", + "from-time": "08:00", + "to-time": "10:00", + }, } } - url = reverse('attendance-list') + url = reverse("attendance-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() - assert ( - json['data']['relationships']['user']['data']['id'] == str(user.id) - ) + assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) def test_attendance_update(auth_client): attendance = AttendanceFactory.create(user=auth_client.user) data = { - 'data': { - 'type': 'attendances', - 'id': attendance.id, - 'attributes': { - 'to-time': '15:00:00' + "data": { + "type": "attendances", + "id": attendance.id, + "attributes": {"to-time": "15:00:00"}, + "relationships": { + "user": {"data": {"id": auth_client.user.id, "type": "users"}} }, - 'relationships': { - 'user': { - 'data': { - 'id': auth_client.user.id, - 'type': 'users' - } - }, - } } } - url = reverse('attendance-detail', args=[attendance.id]) + url = reverse("attendance-detail", args=[attendance.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() assert ( - json['data']['attributes']['to-time'] == - data['data']['attributes']['to-time'] + json["data"]["attributes"]["to-time"] == data["data"]["attributes"]["to-time"] ) def test_attendance_delete(auth_client): attendance = AttendanceFactory.create(user=auth_client.user) - url = reverse('attendance-detail', args=[attendance.id]) + url = reverse("attendance-detail", args=[attendance.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 5c2099368..d9670d762 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -9,8 +9,7 @@ from rest_framework import status from timed.employment.factories import UserFactory -from timed.projects.factories import (CostCenterFactory, ProjectFactory, - TaskFactory) +from timed.projects.factories import CostCenterFactory, ProjectFactory, TaskFactory from timed.tracking.factories import ReportFactory @@ -18,146 +17,138 @@ def test_report_list(auth_client): user = auth_client.user ReportFactory.create(user=user) report = ReportFactory.create(user=user, duration=timedelta(hours=1)) - url = reverse('report-list') - - response = auth_client.get(url, data={ - 'date': report.date, - 'user': user.id, - 'task': report.task_id, - 'project': report.task.project_id, - 'customer': report.task.project.customer_id, - 'include': ( - 'user,task,task.project,task.project.customer,verified_by' - ) - }) + url = reverse("report-list") + + response = auth_client.get( + url, + data={ + "date": report.date, + "user": user.id, + "task": report.task_id, + "project": report.task.project_id, + "customer": report.task.project.customer_id, + "include": ("user,task,task.project,task.project.customer,verified_by"), + }, + ) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(report.id) - assert json['meta']['total-time'] == '01:00:00' + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) + assert json["meta"]["total-time"] == "01:00:00" def test_report_intersection_full(auth_client): report = ReportFactory.create() - url = reverse('report-intersection') - response = auth_client.get(url, data={ - 'ordering': 'task__name', - 'task': report.task.id, - 'project': report.task.project.id, - 'customer': report.task.project.customer.id, - 'include': 'task,customer,project' - }) + url = reverse("report-intersection") + response = auth_client.get( + url, + data={ + "ordering": "task__name", + "task": report.task.id, + "project": report.task.project.id, + "customer": report.task.project.customer.id, + "include": "task,customer,project", + }, + ) assert response.status_code == status.HTTP_200_OK json = response.json() - pk = json['data'].pop('id') - assert 'task={0}'.format(report.task.id) in pk - assert 'project={0}'.format(report.task.project.id) in pk - assert 'customer={0}'.format(report.task.project.customer.id) in pk + pk = json["data"].pop("id") + assert "task={0}".format(report.task.id) in pk + assert "project={0}".format(report.task.project.id) in pk + assert "customer={0}".format(report.task.project.customer.id) in pk - included = json.pop('included') + included = json.pop("included") assert len(included) == 3 expected = { - 'data': { - 'type': 'report-intersections', - 'attributes': { - 'comment': report.comment, - 'not-billable': False, - 'verified': False, - 'review': False + "data": { + "type": "report-intersections", + "attributes": { + "comment": report.comment, + "not-billable": False, + "verified": False, + "review": False, }, - 'relationships': { - 'customer': { - 'data': { - 'id': str(report.task.project.customer.id), - 'type': 'customers' + "relationships": { + "customer": { + "data": { + "id": str(report.task.project.customer.id), + "type": "customers", } }, - 'project': { - 'data': { - 'id': str(report.task.project.id), - 'type': 'projects' - } + "project": { + "data": {"id": str(report.task.project.id), "type": "projects"} }, - 'task': { - 'data': { - 'id': str(report.task.id), - 'type': 'tasks', - } - } + "task": {"data": {"id": str(report.task.id), "type": "tasks"}}, }, }, - 'meta': { - 'count': 1 - } + "meta": {"count": 1}, } assert json == expected def test_report_intersection_partial(auth_client): user = auth_client.user - ReportFactory.create(review=True, not_billable=True, comment='test') - ReportFactory.create(verified_by=user, comment='test') + ReportFactory.create(review=True, not_billable=True, comment="test") + ReportFactory.create(verified_by=user, comment="test") - url = reverse('report-intersection') + url = reverse("report-intersection") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() expected = { - 'data': { - 'id': '', 'type': 'report-intersections', - 'attributes': { - 'comment': 'test', - 'not-billable': None, - 'verified': None, - 'review': None + "data": { + "id": "", + "type": "report-intersections", + "attributes": { + "comment": "test", + "not-billable": None, + "verified": None, + "review": None, }, - 'relationships': { - 'customer': {'data': None}, - 'project': {'data': None}, - 'task': {'data': None} + "relationships": { + "customer": {"data": None}, + "project": {"data": None}, + "task": {"data": None}, }, }, - 'meta': { - 'count': 2 - } + "meta": {"count": 2}, } assert json == expected def test_report_list_filter_id(auth_client): - report_1 = ReportFactory.create(date='2017-01-01') - report_2 = ReportFactory.create(date='2017-02-01') + report_1 = ReportFactory.create(date="2017-01-01") + report_2 = ReportFactory.create(date="2017-02-01") ReportFactory.create() - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={ - 'id': '{0},{1}'.format(report_1.id, report_2.id), - 'ordering': 'id' - }) + response = auth_client.get( + url, data={"id": "{0},{1}".format(report_1.id, report_2.id), "ordering": "id"} + ) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 2 - assert json['data'][0]['id'] == str(report_1.id) - assert json['data'][1]['id'] == str(report_2.id) + assert len(json["data"]) == 2 + assert json["data"][0]["id"] == str(report_1.id) + assert json["data"][1]["id"] == str(report_2.id) def test_report_list_filter_id_empty(auth_client): """Test that empty id filter is ignored.""" ReportFactory.create() - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={'id': ''}) + response = auth_client.get(url, data={"id": ""}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 + assert len(json["data"]) == 1 def test_report_list_filter_reviewer(auth_client): @@ -165,13 +156,13 @@ def test_report_list_filter_reviewer(auth_client): report = ReportFactory.create(user=user) report.task.project.reviewers.add(user) - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={'reviewer': user.id}) + response = auth_client.get(url, data={"reviewer": user.id}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(report.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) def test_report_list_filter_verifier(auth_client): @@ -179,13 +170,13 @@ def test_report_list_filter_verifier(auth_client): report = ReportFactory.create(verified_by=user) ReportFactory.create() - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={'verifier': user.id}) + response = auth_client.get(url, data={"verifier": user.id}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(report.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) def test_report_list_filter_editable_owner(auth_client): @@ -193,13 +184,13 @@ def test_report_list_filter_editable_owner(auth_client): report = ReportFactory.create(user=user) ReportFactory.create() - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={'editable': 1}) + response = auth_client.get(url, data={"editable": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(report.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) def test_report_list_filter_not_editable_owner(auth_client): @@ -207,13 +198,13 @@ def test_report_list_filter_not_editable_owner(auth_client): ReportFactory.create(user=user) report = ReportFactory.create() - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={'editable': 0}) + response = auth_client.get(url, data={"editable": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(report.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) def test_report_list_filter_editable_reviewer(auth_client): @@ -235,35 +226,35 @@ def test_report_list_filter_editable_reviewer(auth_client): reviewer_report = ReportFactory.create() reviewer_report.task.project.reviewers.add(user) - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={'editable': 1}) + response = auth_client.get(url, data={"editable": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 3 + assert len(json["data"]) == 3 def test_report_list_filter_editable_superuser(superadmin_client): report = ReportFactory.create() - url = reverse('report-list') + url = reverse("report-list") - response = superadmin_client.get(url, data={'editable': 1}) + response = superadmin_client.get(url, data={"editable": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 1 - assert json['data'][0]['id'] == str(report.id) + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) def test_report_list_filter_not_editable_superuser(superadmin_client): ReportFactory.create() - url = reverse('report-list') + url = reverse("report-list") - response = superadmin_client.get(url, data={'editable': 0}) + response = superadmin_client.get(url, data={"editable": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 0 + assert len(json["data"]) == 0 def test_report_list_filter_editable_supervisor(auth_client): @@ -283,19 +274,19 @@ def test_report_list_filter_editable_supervisor(auth_client): supervisor_report = ReportFactory.create() supervisor_report.user.supervisors.add(user) - url = reverse('report-list') + url = reverse("report-list") - response = auth_client.get(url, data={'editable': 1}) + response = auth_client.get(url, data={"editable": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json['data']) == 3 + assert len(json["data"]) == 3 def test_report_export_missing_type(auth_client): user = auth_client.user - url = reverse('report-export') + url = reverse("report-export") - response = auth_client.get(url, data={'user': user.id}) + response = auth_client.get(url, data={"user": user.id}) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -304,7 +295,7 @@ def test_report_detail(auth_client): user = auth_client.user report = ReportFactory.create(user=user) - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -316,63 +307,47 @@ def test_report_create(auth_client): task = TaskFactory.create() data = { - 'data': { - 'type': 'reports', - 'id': None, - 'attributes': { - 'comment': 'foo', - 'duration': '00:50:00', - 'date': '2017-02-01' + "data": { + "type": "reports", + "id": None, + "attributes": { + "comment": "foo", + "duration": "00:50:00", + "date": "2017-02-01", + }, + "relationships": { + "task": {"data": {"type": "tasks", "id": task.id}}, + "verified-by": {"data": None}, }, - 'relationships': { - 'task': { - 'data': { - 'type': 'tasks', - 'id': task.id - } - }, - 'verified-by': { - 'data': None - }, - } } } - url = reverse('report-list') + url = reverse("report-list") response = auth_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() - assert ( - json['data']['relationships']['user']['data']['id'] == str(user.id) - ) + assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) - assert json['data']['relationships']['task']['data']['id'] == str(task.id) + assert json["data"]["relationships"]["task"]["data"]["id"] == str(task.id) def test_report_update_bulk(auth_client): task = TaskFactory.create() report = ReportFactory.create(user=auth_client.user) - url = reverse('report-bulk') + url = reverse("report-bulk") data = { - 'data': { - 'type': 'report-bulks', - 'id': None, - 'relationships': { - 'task': { - 'data': { - 'type': 'tasks', - 'id': task.id - } - } - }, + "data": { + "type": "report-bulks", + "id": None, + "relationships": {"task": {"data": {"type": "tasks", "id": task.id}}}, } } - response = auth_client.post(url + '?editable=1', data) + response = auth_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() @@ -382,19 +357,13 @@ def test_report_update_bulk(auth_client): def test_report_update_bulk_verify_non_reviewer(auth_client): ReportFactory.create(user=auth_client.user) - url = reverse('report-bulk') + url = reverse("report-bulk") data = { - 'data': { - 'type': 'report-bulks', - 'id': None, - 'attributes': { - 'verified': True - } - } + "data": {"type": "report-bulks", "id": None, "attributes": {"verified": True}} } - response = auth_client.post(url + '?editable=1', data) + response = auth_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -402,19 +371,13 @@ def test_report_update_bulk_verify_superuser(superadmin_client): user = superadmin_client.user report = ReportFactory.create(user=user) - url = reverse('report-bulk') + url = reverse("report-bulk") data = { - 'data': { - 'type': 'report-bulks', - 'id': None, - 'attributes': { - 'verified': True - } - } + "data": {"type": "report-bulks", "id": None, "attributes": {"verified": True}} } - response = superadmin_client.post(url + '?editable=1', data) + response = superadmin_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() @@ -426,46 +389,35 @@ def test_report_update_bulk_verify_reviewer(auth_client): report = ReportFactory.create(user=user) report.task.project.reviewers.add(user) - url = reverse('report-bulk') + url = reverse("report-bulk") data = { - 'data': { - 'type': 'report-bulks', - 'id': None, - 'attributes': { - 'verified': True, - 'comment': 'some comment' - } + "data": { + "type": "report-bulks", + "id": None, + "attributes": {"verified": True, "comment": "some comment"}, } } - response = auth_client.post( - url + '?editable=1&reviewer={0}'.format(user.id), data - ) + response = auth_client.post(url + "?editable=1&reviewer={0}".format(user.id), data) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() assert report.verified_by == user - assert report.comment == 'some comment' + assert report.comment == "some comment" def test_report_update_bulk_reset_verify(superadmin_client): user = superadmin_client.user report = ReportFactory.create(verified_by=user) - url = reverse('report-bulk') + url = reverse("report-bulk") data = { - 'data': { - 'type': 'report-bulks', - 'id': None, - 'attributes': { - 'verified': False - } - } + "data": {"type": "report-bulks", "id": None, "attributes": {"verified": False}} } - response = superadmin_client.post(url + '?editable=1', data) + response = superadmin_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() @@ -473,15 +425,13 @@ def test_report_update_bulk_reset_verify(superadmin_client): def test_report_update_bulk_not_editable(auth_client): - url = reverse('report-bulk') + url = reverse("report-bulk") data = { - 'data': { - 'type': 'report-bulks', - 'id': None, - 'attributes': { - 'not_billable': True - }, + "data": { + "type": "report-bulks", + "id": None, + "attributes": {"not_billable": True}, } } @@ -496,15 +446,13 @@ def test_report_update_verified_as_non_staff_but_owner(auth_client): user=user, verified_by=user, duration=timedelta(hours=2) ) - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'duration': '01:00:00', - }, + "data": { + "type": "reports", + "id": report.id, + "attributes": {"duration": "01:00:00"}, } } @@ -519,48 +467,33 @@ def test_report_update_owner(auth_client): task = TaskFactory.create() data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', - 'duration': '01:00:00', - 'date': '2017-02-04' + "data": { + "type": "reports", + "id": report.id, + "attributes": { + "comment": "foobar", + "duration": "01:00:00", + "date": "2017-02-04", }, - 'relationships': { - 'task': { - 'data': { - 'type': 'tasks', - 'id': task.id - } - } - } + "relationships": {"task": {"data": {"type": "tasks", "id": task.id}}}, } } - url = reverse('report-detail', args=[ - report.id - ]) + url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() assert ( - json['data']['attributes']['comment'] == - data['data']['attributes']['comment'] + json["data"]["attributes"]["comment"] == data["data"]["attributes"]["comment"] ) assert ( - json['data']['attributes']['duration'] == - data['data']['attributes']['duration'] + json["data"]["attributes"]["duration"] == data["data"]["attributes"]["duration"] ) - assert ( - json['data']['attributes']['date'] == - data['data']['attributes']['date'] - ) - assert ( - json['data']['relationships']['task']['data']['id'] == - str(data['data']['relationships']['task']['data']['id']) + assert json["data"]["attributes"]["date"] == data["data"]["attributes"]["date"] + assert json["data"]["relationships"]["task"]["data"]["id"] == str( + data["data"]["relationships"]["task"]["data"]["id"] ) @@ -570,16 +503,14 @@ def test_report_update_date_reviewer(auth_client): report.task.project.reviewers.add(user) data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'date': '2017-02-04' - }, + "data": { + "type": "reports", + "id": report.id, + "attributes": {"date": "2017-02-04"}, } } - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -591,18 +522,14 @@ def test_report_update_duration_reviewer(auth_client): report.task.project.reviewers.add(user) data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'duration': '01:00:00', - }, + "data": { + "type": "reports", + "id": report.id, + "attributes": {"duration": "01:00:00"}, } } - url = reverse('report-detail', args=[ - report.id - ]) + url = reverse("report-detail", args=[report.id]) res = auth_client.patch(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST @@ -612,16 +539,14 @@ def test_report_update_by_user(auth_client): """Updating of report belonging to different user is not allowed.""" report = ReportFactory.create() data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', - }, + "data": { + "type": "reports", + "id": report.id, + "attributes": {"comment": "foobar"}, } } - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -631,21 +556,16 @@ def test_report_set_verified_by_user(auth_client): user = auth_client.user report = ReportFactory.create(user=user) data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'relationships': { - 'verified-by': { - 'data': { - 'id': user.id, - 'type': 'users' - } - }, - } + "data": { + "type": "reports", + "id": report.id, + "relationships": { + "verified-by": {"data": {"id": user.id, "type": "users"}} + }, } } - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -656,24 +576,17 @@ def test_report_update_reviewer(auth_client): report.task.project.reviewers.add(user) data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', + "data": { + "type": "reports", + "id": report.id, + "attributes": {"comment": "foobar"}, + "relationships": { + "verified-by": {"data": {"id": user.id, "type": "users"}} }, - 'relationships': { - 'verified-by': { - 'data': { - 'id': user.id, - 'type': 'users' - } - }, - } } } - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK @@ -685,16 +598,14 @@ def test_report_update_supervisor(auth_client): report.user.supervisors.add(user) data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', - }, + "data": { + "type": "reports", + "id": report.id, + "attributes": {"comment": "foobar"}, } } - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_200_OK @@ -706,21 +617,16 @@ def test_report_verify_other_user(superadmin_client): report = ReportFactory.create() data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'relationships': { - 'verified-by': { - 'data': { - 'id': user.id, - 'type': 'users' - } - }, - } + "data": { + "type": "reports", + "id": report.id, + "relationships": { + "verified-by": {"data": {"id": user.id, "type": "users"}} + }, } } - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = superadmin_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -732,21 +638,15 @@ def test_report_reset_verified_by_reviewer(auth_client): report.task.project.reviewers.add(user) data = { - 'data': { - 'type': 'reports', - 'id': report.id, - 'attributes': { - 'comment': 'foobar', - }, - 'relationships': { - 'verified-by': { - 'data': None - }, - } + "data": { + "type": "reports", + "id": report.id, + "attributes": {"comment": "foobar"}, + "relationships": {"verified-by": {"data": None}}, } } - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -755,7 +655,7 @@ def test_report_delete(auth_client): user = auth_client.user report = ReportFactory.create(user=user) - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -767,32 +667,32 @@ def test_report_round_duration(db): report.duration = timedelta(hours=1, minutes=7) report.save() - assert duration_string(report.duration) == '01:00:00' + assert duration_string(report.duration) == "01:00:00" report.duration = timedelta(hours=1, minutes=8) report.save() - assert duration_string(report.duration) == '01:15:00' + assert duration_string(report.duration) == "01:15:00" report.duration = timedelta(hours=1, minutes=53) report.save() - assert duration_string(report.duration) == '02:00:00' + assert duration_string(report.duration) == "02:00:00" def test_report_list_no_result(admin_client): - url = reverse('report-list') + url = reverse("report-list") res = admin_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() - assert json['meta']['total-time'] == '00:00:00' + assert json["meta"]["total-time"] == "00:00:00" def test_report_delete_superuser(superadmin_client): """Test that superuser may not delete reports of other users.""" report = ReportFactory.create() - url = reverse('report-detail', args=[report.id]) + url = reverse("report-detail", args=[report.id]) response = superadmin_client.delete(url) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -813,29 +713,27 @@ def test_report_list_filter_cost_center(auth_client): task = TaskFactory.create(cost_center=None, project=project) ReportFactory.create(task=task) - url = reverse('report-list') + url = reverse("report-list") - res = auth_client.get(url, data={'cost_center': cost_center.id}) + res = auth_client.get(url, data={"cost_center": cost_center.id}) assert res.status_code == status.HTTP_200_OK json = res.json() - assert len(json['data']) == 2 - ids = {int(entry['id']) for entry in json['data']} + assert len(json["data"]) == 2 + ids = {int(entry["id"]) for entry in json["data"]} assert {report_task.id, report_project.id} == ids -@pytest.mark.parametrize('file_type', ['csv', 'xlsx', 'ods']) +@pytest.mark.parametrize("file_type", ["csv", "xlsx", "ods"]) def test_report_export(auth_client, file_type, django_assert_num_queries): reports = ReportFactory.create_batch(2) - url = reverse('report-export') + url = reverse("report-export") with django_assert_num_queries(2): - response = auth_client.get(url, data={'file_type': file_type}) + response = auth_client.get(url, data={"file_type": file_type}) assert response.status_code == status.HTTP_200_OK - book = pyexcel.get_book( - file_content=response.content, file_type=file_type - ) + book = pyexcel.get_book(file_content=response.content, file_type=file_type) # bookdict is a dict of tuples(name, content) sheet = book.bookdict.popitem()[1] assert len(sheet) == len(reports) + 1 diff --git a/timed/tracking/urls.py b/timed/tracking/urls.py index fa1811968..7eaeb2db2 100644 --- a/timed/tracking/urls.py +++ b/timed/tracking/urls.py @@ -7,9 +7,9 @@ r = SimpleRouter(trailing_slash=settings.APPEND_SLASH) -r.register(r'activities', views.ActivityViewSet, 'activity') -r.register(r'attendances', views.AttendanceViewSet, 'attendance') -r.register(r'reports', views.ReportViewSet, 'report') -r.register(r'absences', views.AbsenceViewSet, 'absence') +r.register(r"activities", views.ActivityViewSet, "activity") +r.register(r"attendances", views.AttendanceViewSet, "attendance") +r.register(r"reports", views.ReportViewSet, "report") +r.register(r"absences", views.AbsenceViewSet, "absence") urlpatterns = r.urls diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 93732b95f..e7262809a 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -10,9 +10,17 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from timed.permissions import (IsAuthenticated, IsDeleteOnly, IsNotTransferred, - IsOwner, IsReadOnly, IsReviewer, IsSuperUser, - IsSupervisor, IsUnverified) +from timed.permissions import ( + IsAuthenticated, + IsDeleteOnly, + IsNotTransferred, + IsOwner, + IsReadOnly, + IsReviewer, + IsSuperUser, + IsSupervisor, + IsUnverified, +) from timed.serializers import AggregateObject from timed.tracking import filters, models, serializers @@ -21,11 +29,11 @@ class ActivityViewSet(ModelViewSet): """Activity view set.""" serializer_class = serializers.ActivitySerializer - filterset_class = filters.ActivityFilterSet + filterset_class = filters.ActivityFilterSet permission_classes = [ # users may not change transferred activities - C(IsAuthenticated) & C(IsNotTransferred) | - C(IsAuthenticated) & C(IsReadOnly) + C(IsAuthenticated) & C(IsNotTransferred) + | C(IsAuthenticated) & C(IsReadOnly) ] def get_queryset(self): @@ -35,20 +43,15 @@ def get_queryset(self): :rtype: QuerySet """ return models.Activity.objects.select_related( - 'task', - 'user', - 'task__project', - 'task__project__customer' - ).filter( - user=self.request.user - ) + "task", "user", "task__project", "task__project__customer" + ).filter(user=self.request.user) class AttendanceViewSet(ModelViewSet): """Attendance view set.""" serializer_class = serializers.AttendanceSerializer - filterset_class = filters.AttendanceFilterSet + filterset_class = filters.AttendanceFilterSet def get_queryset(self): """Filter the queryset by the user of the request. @@ -56,9 +59,7 @@ def get_queryset(self): :return: The filtered attendances :rtype: QuerySet """ - return models.Attendance.objects.select_related( - 'user' - ).filter( + return models.Attendance.objects.select_related("user").filter( user=self.request.user ) @@ -67,32 +68,34 @@ class ReportViewSet(ModelViewSet): """Report view set.""" serializer_class = serializers.ReportSerializer - filterset_class = filters.ReportFilterSet + filterset_class = filters.ReportFilterSet permission_classes = [ # superuser may edit all reports but not delete - C(IsSuperUser) & ~C(IsDeleteOnly) | + C(IsSuperUser) & ~C(IsDeleteOnly) + | # reviewer and supervisor may change unverified reports # but not delete them - (C(IsReviewer) | C(IsSupervisor)) & - C(IsUnverified) & ~C(IsDeleteOnly) | + (C(IsReviewer) | C(IsSupervisor)) & C(IsUnverified) & ~C(IsDeleteOnly) + | # owner may only change its own unverified reports - C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) | + C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) + | # all authenticated users may read all reports C(IsAuthenticated) & C(IsReadOnly) ] - ordering = ('date', 'id') + ordering = ("date", "id") ordering_fields = ( - 'id', - 'date', - 'duration', - 'task__project__customer__name', - 'task__project__name', - 'task__name', - 'user__username', - 'comment', - 'verified_by__username', - 'review', - 'not_billable' + "id", + "date", + "duration", + "task__project__customer__name", + "task__project__name", + "task__name", + "user__username", + "comment", + "verified_by__username", + "review", + "not_billable", ) def _extract_cost_center(self, report): @@ -102,7 +105,7 @@ def _extract_cost_center(self, report): Cost center of task is prioritized higher than of project. """ - name = '' + name = "" if report.task.project.cost_center: name = report.task.project.cost_center.name @@ -114,7 +117,7 @@ def _extract_cost_center(self, report): def _extract_billing_type(self, report): """Extract billing type from given report.""" - name = '' + name = "" if report.task.project.billing_type: name = report.task.project.billing_type.name @@ -123,7 +126,7 @@ def _extract_billing_type(self, report): @action( detail=False, - methods=['get'], + methods=["get"], serializer_class=serializers.ReportIntersectionSerializer, ) def intersection(self, request): @@ -141,12 +144,7 @@ def intersection(self, request): # filter params represent main indication of result # so it can be used as id params = self.request.query_params.copy() - ignore_params = { - 'ordering', - 'page', - 'page_size', - 'include' - } + ignore_params = {"ordering", "page", "page_size", "include"} for param in ignore_params.intersection(params.keys()): del params[param] @@ -156,10 +154,10 @@ def intersection(self, request): @action( detail=False, - methods=['post'], + methods=["post"], # all users are allowed to bulk update but only on filtered result permission_classes=[IsAuthenticated], - serializer_class=serializers.ReportBulkSerializer + serializer_class=serializers.ReportBulkSerializer, ) def bulk(self, request): user = request.user @@ -173,45 +171,52 @@ def bulk(self, request): for key, value in serializer.validated_data.items() # value equal None means do not touch whereas verified # is handled separately - if value is not None and key != 'verified' + if value is not None and key != "verified" } - editable = request.query_params.get('editable') + editable = request.query_params.get("editable") if not user.is_superuser and not editable: raise exceptions.ParseError( - _('Editable filter needs to be set for bulk update') + _("Editable filter needs to be set for bulk update") ) - verified = serializer.validated_data.get('verified') + verified = serializer.validated_data.get("verified") if verified is not None: # only reviewer or superuser may verify reports # this is enforced when reviewer filter is set to current user - reviewer_id = request.query_params.get('reviewer') + reviewer_id = request.query_params.get("reviewer") if not user.is_superuser and str(reviewer_id) != str(user.id): raise exceptions.ParseError( - _('Reviewer filter needs to be set to verifying user') + _("Reviewer filter needs to be set to verifying user") ) verified_by = verified and user or None - fields['verified_by'] = verified_by + fields["verified_by"] = verified_by if fields: queryset.update(**fields) return Response(status=status.HTTP_204_NO_CONTENT) - @action(methods=['get'], detail=False) + @action(methods=["get"], detail=False) def export(self, request): """Export filtered reports to given file format.""" queryset = self.get_queryset().select_related( - 'task__project__billing_type', - 'task__cost_center', 'task__project__cost_center' + "task__project__billing_type", + "task__cost_center", + "task__project__cost_center", ) queryset = self.filter_queryset(queryset) colnames = [ - 'Date', 'Duration', 'Customer', - 'Project', 'Task', 'User', 'Comment', - 'Billing Type', 'Cost Center' + "Date", + "Duration", + "Customer", + "Project", + "Task", + "User", + "Comment", + "Billing Type", + "Cost Center", ] content = [ [ @@ -228,15 +233,13 @@ def export(self, request): for report in queryset ] - file_type = request.query_params.get('file_type') - if file_type not in ['csv', 'xlsx', 'ods']: + file_type = request.query_params.get("file_type") + if file_type not in ["csv", "xlsx", "ods"]: return HttpResponseBadRequest() - sheet = django_excel.pe.Sheet( - content, name='Report', colnames=colnames - ) + sheet = django_excel.pe.Sheet(content, name="Report", colnames=colnames) return django_excel.make_response( - sheet, file_type=file_type, file_name='report.%s' % file_type + sheet, file_type=file_type, file_name="report.%s" % file_type ) def get_queryset(self): @@ -245,23 +248,24 @@ def get_queryset(self): :return: The filtered reports :rtype: QuerySet """ - return models.Report.objects.select_related( - 'task', - 'user' - ).select_related('task__project', 'task__project__customer') + return models.Report.objects.select_related("task", "user").select_related( + "task__project", "task__project__customer" + ) class AbsenceViewSet(ModelViewSet): """Absence view set.""" serializer_class = serializers.AbsenceSerializer - filterset_class = filters.AbsenceFilterSet + filterset_class = filters.AbsenceFilterSet permission_classes = [ # superuser can change all but not delete - C(IsAuthenticated) & C(IsSuperUser) & ~C(IsDeleteOnly) | + C(IsAuthenticated) & C(IsSuperUser) & ~C(IsDeleteOnly) + | # owner may change all its absences - C(IsAuthenticated) & C(IsOwner) | + C(IsAuthenticated) & C(IsOwner) + | # all authenticated users may read filtered result C(IsAuthenticated) & C(IsReadOnly) ] @@ -269,14 +273,9 @@ class AbsenceViewSet(ModelViewSet): def get_queryset(self): user = self.request.user - queryset = models.Absence.objects.select_related( - 'type', - 'user' - ) + queryset = models.Absence.objects.select_related("type", "user") if not user.is_superuser: - queryset = queryset.filter( - Q(user=user) | Q(user__supervisors=user) - ) + queryset = queryset.filter(Q(user=user) | Q(user__supervisors=user)) return queryset diff --git a/timed/urls.py b/timed/urls.py index ac55b9594..4277f1b8a 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -5,12 +5,12 @@ from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^api/v1/auth/login', obtain_jwt_token, name='login'), - url(r'^api/v1/auth/refresh', refresh_jwt_token, name='refresh'), - url(r'^api/v1/', include('timed.employment.urls')), - url(r'^api/v1/', include('timed.projects.urls')), - url(r'^api/v1/', include('timed.tracking.urls')), - url(r'^api/v1/', include('timed.reports.urls')), - url(r'^api/v1/', include('timed.subscription.urls')) + url(r"^admin/", admin.site.urls), + url(r"^api/v1/auth/login", obtain_jwt_token, name="login"), + url(r"^api/v1/auth/refresh", refresh_jwt_token, name="refresh"), + url(r"^api/v1/", include("timed.employment.urls")), + url(r"^api/v1/", include("timed.projects.urls")), + url(r"^api/v1/", include("timed.tracking.urls")), + url(r"^api/v1/", include("timed.reports.urls")), + url(r"^api/v1/", include("timed.subscription.urls")), ] diff --git a/timed/wsgi.py b/timed/wsgi.py index 587ef8dc7..7e15165e2 100644 --- a/timed/wsgi.py +++ b/timed/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'timed.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timed.settings") application = get_wsgi_application() From 51821e5dbd03f26d5bf1713c20fdd7745acb9f53 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 15 Jan 2019 17:03:28 +0100 Subject: [PATCH 571/980] Introduce contribution guidelines * Introduces pre-commit to simplify contribution * Fixes docker container to install dev dependencies on dev machine --- .pre-commit-config.yaml | 18 ++++++++++ CONTRIBUTING.md | 65 +++++++++++++++++++++++++++++++++++++ Dockerfile | 9 +++-- Makefile | 15 +++------ README.md | 15 ++------- docker-compose.override.yml | 4 +++ 6 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..49bbe46e7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: local + hooks: + - id: black + name: black + language: system + entry: black + types: [python] + - id: isort + name: isort + language: system + entry: isort -y + types: [python] + - id: flake8 + name: flake8 + language: system + entry: flake8 + types: [python] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b2a6a6450 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing + +Contributions to Timed backend are very welcome! Best have a look at the open [issues](https://github.com/adfinis-sygroup/timed-backend) +and open a [GitHub pull request](https://github.com/adfinis-sygroup/timed-backend/compare). See instructions below how to setup development +environment. Before writing any code, best discuss your proposed change in a GitHub issue to see if the proposed change makes sense for the project. + +## Setup development environment + +### Clone + +To work on Timed backend you first need to clone + +```bash +git clone https://github.com/adfinis-sygroup/timed-backend.git +cd timed-backend +``` + +### Open Shell + +Once it is cloned you can easily open a shell in the docker container to +open an development environment. + +```bash +make shell +``` + +### Testing + +Once you have shelled in docker container as described above +you can use common python tooling for formatting, linting, testing +etc. + +```bash +# linting +flake8 +# format code +black . +# running tests +pytest +# create migrations +./manage.py makemigrations +``` + +Writing of code can still happen outside the docker container of course. + +### Install new requirements + +In case you're adding new requirements you simply need to build the docker container +again for those to be installed and re-open shell. + +```bash +docker-compose build --pull +``` + +### Setup pre commit + +Pre commit hooks is an additional option instead of executing checks in your editor of choice. + +First create a virtualenv with the tool of your choice before running below commands: + +```bash +pip install pre-commit +pip install -r requiements-dev.txt -U +pre-commit install +``` diff --git a/Dockerfile b/Dockerfile index aaf44a14d..65bda76fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,14 +8,17 @@ RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ -&& rm -rf /var/lib/apt/lists/* +&& rm -rf /var/lib/apt/lists/* \ +&& mkdir -p /app + +ARG REQUIREMENTS=requirements.txt ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static ENV UWSGI_INI /app/uwsgi.ini -COPY requirements.txt /app -RUN pip install --upgrade -r requirements.txt +COPY requirements.txt requirements-dev.txt /app/ +RUN pip install --upgrade --no-cache-dir --requirement $REQUIREMENTS --disable-pip-version-check COPY . /app diff --git a/Makefile b/Makefile index c86ad97ba..45f07e45e 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,14 @@ -.PHONY: help install install-dev start test +.PHONY: help start test shell .DEFAULT_GOAL := help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -install: ## Install production environment - @docker-compose exec backend pip install --upgrade -r requirements.txt - -install-dev: ## Install development environment - @docker-compose exec backend pip install --upgrade -r requirements-dev.txt - start: ## Start the development server @docker-compose up -d --build test: ## Test the project - @docker-compose exec backend black --check . - @docker-compose exec backend flake8 - @docker-compose exec backend pytest --no-cov-on-fail --cov --create-db + @docker-compose exec backend sh -c "black --check . && flake8 && pytest --no-cov-on-fail --cov --create-db" + +shell: ## Shell into the backend + @docker-compose exec backend bash diff --git a/README.md b/README.md index 459b9cbf2..92b2a407c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) [![Codecov](https://codecov.io/gh/adfinis-sygroup/timed-backend/branch/master/graph/badge.svg)](https://codecov.io/gh/adfinis-sygroup/timed-backend) [![Pyup](https://pyup.io/repos/github/adfinis-sygroup/timed-backend/shield.svg)](https://pyup.io/account/repos/github/adfinis-sygroup/timed-backend/) -[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/adfinis-sygroup/cookiecutter-django-json-api) +[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/adfinis-sygroup/timed-backend) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) Timed timetracking software REST API built with Django @@ -54,18 +54,9 @@ according to type. | `DJANGO_ADMINS` | List of people who get error notifications | not set | | `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | -## Development +## Contributing -For development setup you can set environment variables `ENV=dev`. This way default values will be used. NOT TO BE USED IN PRODUCTION! - -### Testing - -Run tests by executing: - -```bash -make install-dev -make test -``` +Look at our [contributing guidelines](CONTRIBUTION.md) to start with your first contribution. ## License diff --git a/docker-compose.override.yml b/docker-compose.override.yml index eaae2cb8b..0ef8cb56a 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,6 +1,10 @@ version: "3" services: backend: + build: + context: . + args: + REQUIREMENTS: requirements-dev.txt environment: - PYTHONDONTWRITEBYTECODE=1 volumes: From c8bf499b418a2974dd2c5d6c2d6a899d637f8670 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 16 Jan 2019 00:12:22 -0800 Subject: [PATCH 572/980] Update flake8 from 3.5.0 to 3.6.0 (#327) --- requirements-dev.txt | 2 +- timed/employment/views.py | 18 ++++++------------ timed/tracking/views.py | 15 +++++---------- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3521352e5..b0d1feeb5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 -flake8==3.5.0 +flake8==3.6.0 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 diff --git a/timed/employment/views.py b/timed/employment/views.py index 689cfdbbb..094a75e26 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -38,15 +38,12 @@ class UserViewSet(ModelViewSet): permission_classes = [ # only owner, superuser and supervisor may update user (C(IsOwner) | C(IsSuperUser) | C(IsSupervisor)) & C(IsUpdateOnly) - | # only superuser may delete users without reports - C(IsSuperUser) & C(IsDeleteOnly) & C(NoReports) - | + | C(IsSuperUser) & C(IsDeleteOnly) & C(NoReports) # only superuser may create users - C(IsSuperUser) & C(IsCreateOnly) - | + | C(IsSuperUser) & C(IsCreateOnly) # all authenticated users may read - C(IsAuthenticated) & C(IsReadOnly) + | C(IsAuthenticated) & C(IsReadOnly) ] serializer_class = serializers.UserSerializer @@ -267,9 +264,8 @@ class EmploymentViewSet(ModelViewSet): permission_classes = [ # super user can add/read overtime credits C(IsAuthenticated) & C(IsSuperUser) - | # user may only read filtered results - C(IsAuthenticated) & C(IsReadOnly) + | C(IsAuthenticated) & C(IsReadOnly) ] def get_queryset(self): @@ -332,9 +328,8 @@ class AbsenceCreditViewSet(ModelViewSet): permission_classes = [ # super user can add/read absence credits C(IsAuthenticated) & C(IsSuperUser) - | # user may only read filtered results - C(IsAuthenticated) & C(IsReadOnly) + | C(IsAuthenticated) & C(IsReadOnly) ] def get_queryset(self): @@ -364,9 +359,8 @@ class OvertimeCreditViewSet(ModelViewSet): permission_classes = [ # super user can add/read overtime credits C(IsAuthenticated) & C(IsSuperUser) - | # user may only read filtered results - C(IsAuthenticated) & C(IsReadOnly) + | C(IsAuthenticated) & C(IsReadOnly) ] def get_queryset(self): diff --git a/timed/tracking/views.py b/timed/tracking/views.py index e7262809a..23aee89d6 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -72,16 +72,13 @@ class ReportViewSet(ModelViewSet): permission_classes = [ # superuser may edit all reports but not delete C(IsSuperUser) & ~C(IsDeleteOnly) - | # reviewer and supervisor may change unverified reports # but not delete them - (C(IsReviewer) | C(IsSupervisor)) & C(IsUnverified) & ~C(IsDeleteOnly) - | + | (C(IsReviewer) | C(IsSupervisor)) & C(IsUnverified) & ~C(IsDeleteOnly) # owner may only change its own unverified reports - C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) - | + | C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) # all authenticated users may read all reports - C(IsAuthenticated) & C(IsReadOnly) + | C(IsAuthenticated) & C(IsReadOnly) ] ordering = ("date", "id") ordering_fields = ( @@ -262,12 +259,10 @@ class AbsenceViewSet(ModelViewSet): permission_classes = [ # superuser can change all but not delete C(IsAuthenticated) & C(IsSuperUser) & ~C(IsDeleteOnly) - | # owner may change all its absences - C(IsAuthenticated) & C(IsOwner) - | + | C(IsAuthenticated) & C(IsOwner) # all authenticated users may read filtered result - C(IsAuthenticated) & C(IsReadOnly) + | C(IsAuthenticated) & C(IsReadOnly) ] def get_queryset(self): From efa6feb05930199863a22690bbea5e9ed146aceb Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 16 Jan 2019 05:49:07 -0800 Subject: [PATCH 573/980] Update djangorestframework from 3.9.0 to 3.9.1 (#373) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ccf497503..87c0812d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==1.11.18 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.0.0 django-multiselectfield==0.1.8 -djangorestframework==3.9.0 +djangorestframework==3.9.1 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.6.1 From de0cb0522272c095bae8671c0e8e53f0003d396c Mon Sep 17 00:00:00 2001 From: Patrick Winter Date: Fri, 18 Jan 2019 11:10:58 +0100 Subject: [PATCH 574/980] Update CONTRIBUTING.md Co-Authored-By: sliverc --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2a6a6450..89273b96f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ make shell ### Testing -Once you have shelled in docker container as described above +Once you have shelled in to the docker container as described above you can use common python tooling for formatting, linting, testing etc. From 69d0e0ddf69f278b1c9cf208729d97bf79475dd9 Mon Sep 17 00:00:00 2001 From: Patrick Winter Date: Fri, 18 Jan 2019 11:11:07 +0100 Subject: [PATCH 575/980] Update CONTRIBUTING.md Co-Authored-By: sliverc --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89273b96f..657618e95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ cd timed-backend ### Open Shell Once it is cloned you can easily open a shell in the docker container to -open an development environment. +open a development environment. ```bash make shell From 89441a940eda8b2ab45271e6fb1fb17155bc72ef Mon Sep 17 00:00:00 2001 From: Patrick Winter Date: Fri, 18 Jan 2019 11:11:40 +0100 Subject: [PATCH 576/980] Update CONTRIBUTING.md Co-Authored-By: sliverc --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 657618e95..e2e795966 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,7 +46,7 @@ Writing of code can still happen outside the docker container of course. ### Install new requirements In case you're adding new requirements you simply need to build the docker container -again for those to be installed and re-open shell. +again for them to be installed and re-open shell. ```bash docker-compose build --pull From cf1851a1d38463be5dc7776d3526a3ee85113efb Mon Sep 17 00:00:00 2001 From: Patrick Winter Date: Fri, 18 Jan 2019 11:11:50 +0100 Subject: [PATCH 577/980] Update CONTRIBUTING.md Co-Authored-By: sliverc --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2e795966..13f93f27e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ docker-compose build --pull ### Setup pre commit -Pre commit hooks is an additional option instead of executing checks in your editor of choice. +Pre commit hooks are an additional option instead of executing checks in your editor of choice. First create a virtualenv with the tool of your choice before running below commands: From e437a777681b82de80ea3c9b19d23ff9859c46dc Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 20 Jan 2019 23:44:06 -0800 Subject: [PATCH 578/980] Update django-filter from 2.0.0 to 2.1.0 (#374) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 87c0812d2..44e9eb96f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ python-dateutil==2.7.5 django==1.11.18 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 -django-filter==2.0.0 +django-filter==2.1.0 django-multiselectfield==0.1.8 djangorestframework==3.9.1 djangorestframework-jwt==1.11.0 From a73aae34f86ab9eaae8cad361f4eda67b46dc5ba Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 23 Jan 2019 00:45:25 -0800 Subject: [PATCH 579/980] Update psycopg2-binary from 2.7.6.1 to 2.7.7 (#375) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 44e9eb96f..3f6bbaa3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.9.1 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 -psycopg2-binary==2.7.6.1 +psycopg2-binary==2.7.7 pytz==2018.9 pyexcel-webio==0.1.4 pyexcel-io==0.5.11 From 32bec2979465b07841892f1b716ed65c01780356 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 29 Jan 2019 23:38:13 -0800 Subject: [PATCH 580/980] Update flake8 from 3.6.0 to 3.7.0 (#376) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b0d1feeb5..4671161a2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 -flake8==3.6.0 +flake8==3.7.0 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 From 4f60bac3d0829aeaba1112c2909e3f9bf05dd9c9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 30 Jan 2019 01:00:25 -0800 Subject: [PATCH 581/980] Update flake8 from 3.7.0 to 3.7.1 (#377) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4671161a2..bfa690fb6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 -flake8==3.7.0 +flake8==3.7.1 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 From 7b7f50dc632152da2c6e6fe01b621d7649629c35 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 30 Jan 2019 23:41:51 -0800 Subject: [PATCH 582/980] Update flake8 from 3.7.1 to 3.7.3 (#380) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index bfa690fb6..05287022f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 -flake8==3.7.1 +flake8==3.7.3 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 From df76ffc5b90a6ded3d46ef01bc763cc2ba5574c6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 30 Jan 2019 23:51:19 -0800 Subject: [PATCH 583/980] Update pytest from 4.1.1 to 4.2.0 (#379) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 05287022f..fbc39cfe0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==4.1.1 +pytest==4.2.0 pytest-cov==2.6.1 pytest-django==3.4.5 pytest-env==0.6.2 From 01113db06502df6981067fa9a63e1984dbec3e7c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 31 Jan 2019 23:27:01 -0800 Subject: [PATCH 584/980] Update flake8 from 3.7.3 to 3.7.4 (#381) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index fbc39cfe0..d536b9115 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 -flake8==3.7.3 +flake8==3.7.4 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 From 88dc8441630244a06592c5f64b1f3ab9be368c4d Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sun, 3 Feb 2019 20:24:25 +0100 Subject: [PATCH 585/980] Update pytest-django from 3.4.5 to 3.4.7 --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d536b9115..75a916190 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==4.3.4 mockldap==0.3.0 pytest==4.2.0 pytest-cov==2.6.1 -pytest-django==3.4.5 +pytest-django==3.4.7 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 pytest-mock==1.10.0 From 5d3e9188b1df9c470a7f48f6195c2ac37d659472 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 4 Feb 2019 06:35:51 -0800 Subject: [PATCH 586/980] Update pytest-mock from 1.10.0 to 1.10.1 (#384) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 75a916190..bf0a9bfc2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -17,5 +17,5 @@ pytest-cov==2.6.1 pytest-django==3.4.7 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 -pytest-mock==1.10.0 +pytest-mock==1.10.1 pytest-randomly==1.2.3 From 11cb3e2cdbc5bec74d632f5e1d90297325ba94c2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 4 Feb 2019 23:38:25 -0800 Subject: [PATCH 587/980] Update flake8 from 3.7.4 to 3.7.5 (#385) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index bf0a9bfc2..3beb2d83e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 -flake8==3.7.4 +flake8==3.7.5 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 From 55c88aa8cfa256f06eefb5f868d05b5d221851da Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 5 Feb 2019 06:40:55 -0800 Subject: [PATCH 588/980] Update python-dateutil from 2.7.5 to 2.8.0 (#386) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3f6bbaa3a..22432db46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.7.5 +python-dateutil==2.8.0 django==1.11.18 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 From 9a5a6ad5ba84f50fd454da3a5c38258abcbeab0e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Feb 2019 00:13:35 -0800 Subject: [PATCH 589/980] Update uwsgi from 2.0.17.1 to 2.0.18 (#388) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 22432db46..4e55977dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ django-environ==0.4.5 rest_condition==1.0.3 django-money==0.14.4 python-redmine==2.2.0 -uwsgi==2.0.17.1 +uwsgi==2.0.18 From 38f7dcd0db897cc5607bd10e1743e08d27142959 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Feb 2019 02:47:49 -0800 Subject: [PATCH 590/980] Update django from 1.11.18 to 1.11.19 (#389) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4e55977dc..1c6884f98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.0 -django==1.11.18 # pyup: >=1.11,<1.12 +django==1.11.19 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 django-multiselectfield==0.1.8 From 657c0a5daef99d363119c66ff8c60300effec035 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Feb 2019 07:58:29 -0800 Subject: [PATCH 591/980] Update django from 1.11.19 to 1.11.20 (#390) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1c6884f98..a84a50d60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.0 -django==1.11.19 # pyup: >=1.11,<1.12 +django==1.11.20 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 django-multiselectfield==0.1.8 From 0329c5e50ac0de2fb653e454e55b303116bc83f6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Feb 2019 23:59:28 -0800 Subject: [PATCH 592/980] Update pyexcel-io from 0.5.11 to 0.5.13 (#391) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a84a50d60..0c18e4ab5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.7 pytz==2018.9 pyexcel-webio==0.1.4 -pyexcel-io==0.5.11 +pyexcel-io==0.5.13 django-excel==0.0.10 pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.6 From a26d75396914416c4b20a767c8e7c3efd95eca84 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 13 Feb 2019 04:08:31 -0800 Subject: [PATCH 593/980] Update pytest from 4.2.0 to 4.2.1 (#392) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3beb2d83e..4043d0e8d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==4.2.0 +pytest==4.2.1 pytest-cov==2.6.1 pytest-django==3.4.7 pytest-env==0.6.2 From a428bad63f0ed60cdc7dab802f49eb6cbdfae7ca Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Feb 2019 16:11:13 +0200 Subject: [PATCH 594/980] Update pyexcel-xlsx from 0.5.6 to 0.5.7 (#393) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0c18e4ab5..c33c9d9d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyexcel-webio==0.1.4 pyexcel-io==0.5.13 django-excel==0.0.10 pyexcel-ods3==0.5.3 -pyexcel-xlsx==0.5.6 +pyexcel-xlsx==0.5.7 pyexcel-ezodf==0.3.4 django-environ==0.4.5 rest_condition==1.0.3 From ebc0b16877881de168416c65d356ed51e1d86e6f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 19 Feb 2019 09:44:44 +0200 Subject: [PATCH 595/980] Update flake8 from 3.7.5 to 3.7.6 (#394) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4043d0e8d..d3ecc70ba 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.2 factory-boy==2.11.1 -flake8==3.7.5 +flake8==3.7.6 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 From 0314910c4c22e620dc46255ddcc6d40b20d22a26 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 19 Feb 2019 09:49:14 +0200 Subject: [PATCH 596/980] Update pytest from 4.2.1 to 4.3.0 (#395) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d3ecc70ba..4fd9b427c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.4 mockldap==0.3.0 -pytest==4.2.1 +pytest==4.3.0 pytest-cov==2.6.1 pytest-django==3.4.7 pytest-env==0.6.2 From a4b021d0f9b05f80d510f5b2f7f4c340163d0e7a Mon Sep 17 00:00:00 2001 From: sbor23 Date: Wed, 20 Feb 2019 08:44:14 +0100 Subject: [PATCH 597/980] Fix duplicated absences for supervisees (#396) This fixes a bug where supervisees had their absences duplicated by the number of supervisors they have. --- .gitignore | 3 +++ timed/tracking/tests/test_absence.py | 25 +++++++++++++++++++++++++ timed/tracking/views.py | 4 +++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cba1df755..c84cfc961 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ target/ # pytest .pytest_cache + +# PyCharm +.idea diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 3ed223ec1..dbb05dfa0 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -59,6 +59,31 @@ def test_absence_list_supervisor(auth_client): assert len(json["data"]) == 2 +def test_absence_list_supervisee(auth_client): + AbsenceFactory.create(user=auth_client.user) + + supervisors = UserFactory.create_batch(2) + + supervisors[0].supervisees.add(auth_client.user) + AbsenceFactory.create(user=supervisors[0]) + + url = reverse("absence-list") + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json["data"]) == 1 + + # absences of multiple supervisors shouldn't affect supervisee + supervisors[1].supervisees.add(auth_client.user) + AbsenceFactory.create(user=supervisors[1]) + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json["data"]) == 1 + + def test_absence_detail(auth_client): absence = AbsenceFactory.create(user=auth_client.user) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 23aee89d6..dc7406d25 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -271,6 +271,8 @@ def get_queryset(self): queryset = models.Absence.objects.select_related("type", "user") if not user.is_superuser: - queryset = queryset.filter(Q(user=user) | Q(user__supervisors=user)) + queryset = queryset.filter( + Q(user=user) | Q(user__in=user.supervisees.all()) + ) return queryset From ecccbde517b88f8cafaa3c5b72eafe21781f17eb Mon Sep 17 00:00:00 2001 From: sbor23 Date: Wed, 20 Feb 2019 10:54:49 +0100 Subject: [PATCH 598/980] Remove rest_condition for REST framework operators (#397) Replace existing permission_classes composed using rest_condition operator `C()` with native operators. Introduce new Permission `IsNotDelete` as complement to `IsDeleteOnly` because there's no negation allowed, only bitwise `&` and `|`. Get rid of rest_condition dependency. --- pytest.ini | 5 ----- requirements.txt | 1 - setup.py | 1 - timed/employment/views.py | 21 ++++++++++----------- timed/permissions.py | 10 ++++++++++ timed/tracking/views.py | 21 ++++++++++----------- 6 files changed, 30 insertions(+), 29 deletions(-) diff --git a/pytest.ini b/pytest.ini index 63d5567e0..daa784b07 100644 --- a/pytest.ini +++ b/pytest.ini @@ -15,8 +15,3 @@ filterwarnings = # TODO: adjust frontend for this change to work ignore:PageNumberPagination is deprecated. Use JsonApiPageNumberPagination instead. - - # This warning is caused by rest_conditions - # TODO: replace rest_conditions with composable permission classes - # https://www.django-rest-framework.org/community/3.9-announcement/#composable-permission-classes - ignore:Using user\.is_authenticated\(\) and user\.is_anonymous\(\) diff --git a/requirements.txt b/requirements.txt index c33c9d9d4..80ebf18fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,6 @@ pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.7 pyexcel-ezodf==0.3.4 django-environ==0.4.5 -rest_condition==1.0.3 django-money==0.14.4 python-redmine==2.2.0 uwsgi==2.0.18 diff --git a/setup.py b/setup.py index ae92609c1..ff61bcdcb 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,6 @@ def find_data(packages, extensions): "pyexcel-xlsx", "pyexcel-ezodf", "django-environ", - "rest_condition", "django-money", "python-redmine", ), diff --git a/timed/employment/views.py b/timed/employment/views.py index 094a75e26..7fd90d026 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -5,7 +5,6 @@ from django.db.models import CharField, DateField, IntegerField, Q, Value from django.db.models.functions import Concat from django.utils.translation import ugettext_lazy as _ -from rest_condition import C from rest_framework import exceptions, status from rest_framework.decorators import action from rest_framework.response import Response @@ -37,13 +36,13 @@ class UserViewSet(ModelViewSet): permission_classes = [ # only owner, superuser and supervisor may update user - (C(IsOwner) | C(IsSuperUser) | C(IsSupervisor)) & C(IsUpdateOnly) + (IsOwner | IsSuperUser | IsSupervisor) & IsUpdateOnly # only superuser may delete users without reports - | C(IsSuperUser) & C(IsDeleteOnly) & C(NoReports) + | IsSuperUser & IsDeleteOnly & NoReports # only superuser may create users - | C(IsSuperUser) & C(IsCreateOnly) + | IsSuperUser & IsCreateOnly # all authenticated users may read - | C(IsAuthenticated) & C(IsReadOnly) + | IsAuthenticated & IsReadOnly ] serializer_class = serializers.UserSerializer @@ -263,9 +262,9 @@ class EmploymentViewSet(ModelViewSet): filterset_class = filters.EmploymentFilterSet permission_classes = [ # super user can add/read overtime credits - C(IsAuthenticated) & C(IsSuperUser) + IsAuthenticated & IsSuperUser # user may only read filtered results - | C(IsAuthenticated) & C(IsReadOnly) + | IsAuthenticated & IsReadOnly ] def get_queryset(self): @@ -327,9 +326,9 @@ class AbsenceCreditViewSet(ModelViewSet): serializer_class = serializers.AbsenceCreditSerializer permission_classes = [ # super user can add/read absence credits - C(IsAuthenticated) & C(IsSuperUser) + IsAuthenticated & IsSuperUser # user may only read filtered results - | C(IsAuthenticated) & C(IsReadOnly) + | IsAuthenticated & IsReadOnly ] def get_queryset(self): @@ -358,9 +357,9 @@ class OvertimeCreditViewSet(ModelViewSet): serializer_class = serializers.OvertimeCreditSerializer permission_classes = [ # super user can add/read overtime credits - C(IsAuthenticated) & C(IsSuperUser) + IsAuthenticated & IsSuperUser # user may only read filtered results - | C(IsAuthenticated) & C(IsReadOnly) + | IsAuthenticated & IsReadOnly ] def get_queryset(self): diff --git a/timed/permissions.py b/timed/permissions.py index d9a4dbb38..f1bd48364 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -28,6 +28,16 @@ def has_object_permission(self, request, view, obj): return self.has_permission(request, view) +class IsNotDelete(BasePermission): + """Disallow delete method.""" + + def has_permission(self, request, view): + return request.method != "DELETE" + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + class IsCreateOnly(BasePermission): """Allows only create method.""" diff --git a/timed/tracking/views.py b/timed/tracking/views.py index dc7406d25..8342727a2 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -4,7 +4,6 @@ from django.db.models import Q from django.http import HttpResponseBadRequest from django.utils.translation import ugettext_lazy as _ -from rest_condition import C from rest_framework import exceptions, status from rest_framework.decorators import action from rest_framework.response import Response @@ -12,7 +11,7 @@ from timed.permissions import ( IsAuthenticated, - IsDeleteOnly, + IsNotDelete, IsNotTransferred, IsOwner, IsReadOnly, @@ -32,8 +31,8 @@ class ActivityViewSet(ModelViewSet): filterset_class = filters.ActivityFilterSet permission_classes = [ # users may not change transferred activities - C(IsAuthenticated) & C(IsNotTransferred) - | C(IsAuthenticated) & C(IsReadOnly) + IsAuthenticated & IsNotTransferred + | IsAuthenticated & IsReadOnly ] def get_queryset(self): @@ -71,14 +70,14 @@ class ReportViewSet(ModelViewSet): filterset_class = filters.ReportFilterSet permission_classes = [ # superuser may edit all reports but not delete - C(IsSuperUser) & ~C(IsDeleteOnly) + IsSuperUser & IsNotDelete # reviewer and supervisor may change unverified reports # but not delete them - | (C(IsReviewer) | C(IsSupervisor)) & C(IsUnverified) & ~C(IsDeleteOnly) + | (IsReviewer | IsSupervisor) & IsUnverified & IsNotDelete # owner may only change its own unverified reports - | C(IsAuthenticated) & C(IsOwner) & C(IsUnverified) + | IsAuthenticated & IsOwner & IsUnverified # all authenticated users may read all reports - | C(IsAuthenticated) & C(IsReadOnly) + | IsAuthenticated & IsReadOnly ] ordering = ("date", "id") ordering_fields = ( @@ -258,11 +257,11 @@ class AbsenceViewSet(ModelViewSet): permission_classes = [ # superuser can change all but not delete - C(IsAuthenticated) & C(IsSuperUser) & ~C(IsDeleteOnly) + IsAuthenticated & IsSuperUser & IsNotDelete # owner may change all its absences - | C(IsAuthenticated) & C(IsOwner) + | IsAuthenticated & IsOwner # all authenticated users may read filtered result - | C(IsAuthenticated) & C(IsReadOnly) + | IsAuthenticated & IsReadOnly ] def get_queryset(self): From 80b739d348e017a810df9ef92d28ce79ad0af593 Mon Sep 17 00:00:00 2001 From: sbor23 Date: Wed, 20 Feb 2019 14:43:31 +0100 Subject: [PATCH 599/980] Disallow deletion of BillingType and CostCenter (#398) Disallow deletion of BillingType and CostCenter if they are referenced in a project and/or task. This is propagated into the admin site as well. --- .../migrations/0008_auto_20190220_1133.py | 36 +++++++++++++++++++ timed/projects/models.py | 4 +-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 timed/projects/migrations/0008_auto_20190220_1133.py diff --git a/timed/projects/migrations/0008_auto_20190220_1133.py b/timed/projects/migrations/0008_auto_20190220_1133.py new file mode 100644 index 000000000..061d445f8 --- /dev/null +++ b/timed/projects/migrations/0008_auto_20190220_1133.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-02-20 10:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [("projects", "0007_project_subscription_project")] + + operations = [ + migrations.AlterField( + model_name="project", + name="billing_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="projects", + to="projects.BillingType", + ), + ), + migrations.AlterField( + model_name="project", + name="cost_center", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="projects", + to="projects.CostCenter", + ), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 42a7ae221..441ec661b 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -75,14 +75,14 @@ class Project(models.Model): ) billing_type = models.ForeignKey( BillingType, - on_delete=models.SET_NULL, + on_delete=models.PROTECT, blank=True, null=True, related_name="projects", ) cost_center = models.ForeignKey( CostCenter, - on_delete=models.SET_NULL, + on_delete=models.PROTECT, blank=True, null=True, related_name="projects", From 0896296067674d82ae7f6160288bf43da2f56d98 Mon Sep 17 00:00:00 2001 From: sbor23 Date: Thu, 21 Feb 2019 09:15:16 +0100 Subject: [PATCH 600/980] Version bump to 0.12.1 (#399) --- timed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/__init__.py b/timed/__init__.py index ea370a8e5..def467e07 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "0.12.0" +__version__ = "0.12.1" From f430528561e9cdffed6d89bf09486a83a9ea70ba Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 21 Feb 2019 10:49:27 +0200 Subject: [PATCH 601/980] Update pyexcel-io from 0.5.13 to 0.5.14 (#400) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 80ebf18fa..a8e3adaa5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.7 pytz==2018.9 pyexcel-webio==0.1.4 -pyexcel-io==0.5.13 +pyexcel-io==0.5.14 django-excel==0.0.10 pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.7 From 519e8c89ffcd9167b6e1ac3474eaf4f5450ef1eb Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Feb 2019 09:19:05 +0200 Subject: [PATCH 602/980] Update isort from 4.3.4 to 4.3.5 (#401) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4fd9b427c..133f38097 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.6.0 flake8-string-format==0.2.3 ipdb==0.11 -isort==4.3.4 +isort==4.3.5 mockldap==0.3.0 pytest==4.3.0 pytest-cov==2.6.1 From 64cd68af0fb54b764bfb676963789aa58af1cab9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Feb 2019 10:18:22 +0200 Subject: [PATCH 603/980] Update isort from 4.3.5 to 4.3.6 (#402) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 133f38097..ad5ea4999 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.6.0 flake8-string-format==0.2.3 ipdb==0.11 -isort==4.3.5 +isort==4.3.6 mockldap==0.3.0 pytest==4.3.0 pytest-cov==2.6.1 From cdd23d6150c50c57b939bb9b505305bcd31912b2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Mar 2019 09:55:19 +0200 Subject: [PATCH 604/980] Update pytest-django from 3.4.7 to 3.4.8 (#407) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index ad5ea4999..f0a5ae96f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==4.3.6 mockldap==0.3.0 pytest==4.3.0 pytest-cov==2.6.1 -pytest-django==3.4.7 +pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 pytest-mock==1.10.1 From 06447e5cf022fedc00a0b7fc3feb018e0dc3d245 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Mar 2019 10:00:07 +0200 Subject: [PATCH 605/980] Update pytest-randomly from 1.2.3 to 2.1.0 (#409) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f0a5ae96f..501b3d745 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,4 +18,4 @@ pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 pytest-mock==1.10.1 -pytest-randomly==1.2.3 +pytest-randomly==2.1.0 From c4b67225c67d84d9987163ab9a3760e307d7a714 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Mar 2019 10:10:57 +0200 Subject: [PATCH 606/980] Update coverage from 4.5.2 to 4.5.3 (#417) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 501b3d745..4e32beb52 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt black==18.9b0 -coverage==4.5.2 +coverage==4.5.3 factory-boy==2.11.1 flake8==3.7.6 flake8-blind-except==0.1.1 From e0589b69458e9888cfffa5c34029fa57251fd820 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Mar 2019 10:20:57 +0200 Subject: [PATCH 607/980] Update isort from 4.3.6 to 4.3.15 (#418) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4e32beb52..92b08aec7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.6.0 flake8-string-format==0.2.3 ipdb==0.11 -isort==4.3.6 +isort==4.3.15 mockldap==0.3.0 pytest==4.3.0 pytest-cov==2.6.1 From e1c52982def14189936c90a1a2fc6e37ffe35422 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Mar 2019 10:33:43 +0200 Subject: [PATCH 608/980] Update python-redmine from 2.2.0 to 2.2.1 (#410) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a8e3adaa5..a039641f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ pyexcel-xlsx==0.5.7 pyexcel-ezodf==0.3.4 django-environ==0.4.5 django-money==0.14.4 -python-redmine==2.2.0 +python-redmine==2.2.1 uwsgi==2.0.18 From 5cffd1673318608f65018c9f87a19d062ef41409 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Mar 2019 10:36:59 +0200 Subject: [PATCH 609/980] Update djangorestframework from 3.9.1 to 3.9.2 (#412) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a039641f5..363eb1909 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==1.11.20 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 django-multiselectfield==0.1.8 -djangorestframework==3.9.1 +djangorestframework==3.9.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.7 From f076c4f85fa9cbaf46eeaf82f0a7ccb716f79153 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Mar 2019 10:40:29 +0200 Subject: [PATCH 610/980] Update flake8 from 3.7.6 to 3.7.7 (#405) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 92b08aec7..4ab24353d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==18.9b0 coverage==4.5.3 factory-boy==2.11.1 -flake8==3.7.6 +flake8==3.7.7 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 From 896fdb0314ac3571417e249a8b5ad0ccc01df356 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 13 Mar 2019 11:52:01 +0200 Subject: [PATCH 611/980] Update pytest from 4.3.0 to 4.3.1 (#419) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4ab24353d..613475746 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.15 mockldap==0.3.0 -pytest==4.3.0 +pytest==4.3.1 pytest-cov==2.6.1 pytest-django==3.4.8 pytest-env==0.6.2 From 98cf2965a0b6621b223326c36990d601cfb13dda Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Mar 2019 09:11:59 +0200 Subject: [PATCH 612/980] Update pyexcel-io from 0.5.14 to 0.5.15 (#421) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 363eb1909..5dd825396 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.7 pytz==2018.9 pyexcel-webio==0.1.4 -pyexcel-io==0.5.14 +pyexcel-io==0.5.15 django-excel==0.0.10 pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.7 From 7499fe69641b4450e9d51e1c9f1feee9f31b30c8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Mar 2019 09:31:09 +0200 Subject: [PATCH 613/980] Update black from 18.9b0 to 19.3b0 (#420) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 613475746..8febfdc4b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==18.9b0 +black==19.3b0 coverage==4.5.3 factory-boy==2.11.1 flake8==3.7.7 From 594e0df806e1a15e93ce3f95c1db15ba2d212bd5 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 19 Mar 2019 14:03:59 +0200 Subject: [PATCH 614/980] Update pyexcel-io from 0.5.15 to 0.5.16 (#422) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5dd825396..da7cce96c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.7 pytz==2018.9 pyexcel-webio==0.1.4 -pyexcel-io==0.5.15 +pyexcel-io==0.5.16 django-excel==0.0.10 pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.7 From 6e76771928606a8957a80c022cd407b6206f6c01 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 20 Mar 2019 09:03:01 +0200 Subject: [PATCH 615/980] Update flake8-isort from 2.6.0 to 2.7.0 (#423) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8febfdc4b..70845c5a4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 -flake8-isort==2.6.0 +flake8-isort==2.7.0 flake8-string-format==0.2.3 ipdb==0.11 isort==4.3.15 From 4d748c47e4cd956c55b422f5ff90cfc6b9fe3bf1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Mar 2019 09:10:18 +0200 Subject: [PATCH 616/980] Update ipdb from 0.11 to 0.12 (#424) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 70845c5a4..4437233a2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,7 +9,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-isort==2.7.0 flake8-string-format==0.2.3 -ipdb==0.11 +ipdb==0.12 isort==4.3.15 mockldap==0.3.0 pytest==4.3.1 From 2d5edb07366e57a30274e3f526e4be0c767d9e41 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Mar 2019 09:19:35 +0200 Subject: [PATCH 617/980] Update isort from 4.3.15 to 4.3.16 (#425) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4437233a2..77a7900f9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.7.0 flake8-string-format==0.2.3 ipdb==0.12 -isort==4.3.15 +isort==4.3.16 mockldap==0.3.0 pytest==4.3.1 pytest-cov==2.6.1 From eac036437af746ddef6633744f29eac141581cd8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Mar 2019 14:16:04 +0200 Subject: [PATCH 618/980] Update pytest-mock from 1.10.1 to 1.10.2 (#426) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 77a7900f9..59e213c20 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -17,5 +17,5 @@ pytest-cov==2.6.1 pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 -pytest-mock==1.10.1 +pytest-mock==1.10.2 pytest-randomly==2.1.0 From 571baa44e370fb7b2a6c171205cfe8bcf0006073 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 27 Mar 2019 09:19:36 +0200 Subject: [PATCH 619/980] Update pytest-randomly from 2.1.0 to 2.1.1 (#427) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 59e213c20..53349a2ff 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,4 +18,4 @@ pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 pytest-mock==1.10.2 -pytest-randomly==2.1.0 +pytest-randomly==2.1.1 From 0b302989b9aac2e372e37256386e5aff230e8620 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 1 Apr 2019 08:05:45 +0200 Subject: [PATCH 620/980] Update pytest from 4.3.1 to 4.4.0 (#429) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 53349a2ff..0b79aae3f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.12 isort==4.3.16 mockldap==0.3.0 -pytest==4.3.1 +pytest==4.4.0 pytest-cov==2.6.1 pytest-django==3.4.8 pytest-env==0.6.2 From ad69f74219df7fe159a1ab30cd5f02ddb9731e7c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 1 Apr 2019 08:18:18 +0200 Subject: [PATCH 621/980] Update pytest-mock from 1.10.2 to 1.10.3 (#428) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0b79aae3f..69f01f94d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -17,5 +17,5 @@ pytest-cov==2.6.1 pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 -pytest-mock==1.10.2 +pytest-mock==1.10.3 pytest-randomly==2.1.1 From 48566e51374051756b69515c396ecbe71fb282fe Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 5 Apr 2019 09:23:37 +0200 Subject: [PATCH 622/980] Update pyexcel-io from 0.5.16 to 0.5.17 (#431) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index da7cce96c..ca57d669d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.7.7 pytz==2018.9 pyexcel-webio==0.1.4 -pyexcel-io==0.5.16 +pyexcel-io==0.5.17 django-excel==0.0.10 pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.7 From f4a016bdd9737540c302273585de661a2dcc2212 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 5 Apr 2019 09:46:11 +0200 Subject: [PATCH 623/980] Update psycopg2-binary from 2.7.7 to 2.8 (#430) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca57d669d..6e723cf3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.9.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 -psycopg2-binary==2.7.7 +psycopg2-binary==2.8 pytz==2018.9 pyexcel-webio==0.1.4 pyexcel-io==0.5.17 From 435432100f16da6dc4a6e4b03c7e32b3e3242dc4 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 5 Apr 2019 14:55:21 +0200 Subject: [PATCH 624/980] Update pytest-randomly from 2.1.1 to 3.0.0 (#432) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 69f01f94d..7366245a6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,4 +18,4 @@ pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 pytest-mock==1.10.3 -pytest-randomly==2.1.1 +pytest-randomly==3.0.0 From 771b9465476d50af1412791acd01e96f6df72b10 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 8 Apr 2019 09:29:51 +0200 Subject: [PATCH 625/980] Update psycopg2-binary from 2.8 to 2.8.1 (#433) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6e723cf3e..d676ddb76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.9.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 -psycopg2-binary==2.8 +psycopg2-binary==2.8.1 pytz==2018.9 pyexcel-webio==0.1.4 pyexcel-io==0.5.17 From 65e43fe8e1a874a9776abb4d51537eef04f8523a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 8 Apr 2019 09:46:18 +0200 Subject: [PATCH 626/980] Update isort from 4.3.16 to 4.3.17 (#434) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7366245a6..8be18ce51 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.7.0 flake8-string-format==0.2.3 ipdb==0.12 -isort==4.3.16 +isort==4.3.17 mockldap==0.3.0 pytest==4.4.0 pytest-cov==2.6.1 From cd3082cfa9d3d4dca721cb92596fc474ec4dc16c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 16 Apr 2019 08:28:58 +0200 Subject: [PATCH 627/980] Update pytest from 4.4.0 to 4.4.1 (#437) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8be18ce51..58c984fdc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.12 isort==4.3.17 mockldap==0.3.0 -pytest==4.4.0 +pytest==4.4.1 pytest-cov==2.6.1 pytest-django==3.4.8 pytest-env==0.6.2 From 0cd4e50459923e6a3e01a70a80075d49d76776c1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 16 Apr 2019 08:51:40 +0200 Subject: [PATCH 628/980] Update pytz from 2018.9 to 2019.1 (#435) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d676ddb76..58ab4d1d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.9.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.8.1 -pytz==2018.9 +pytz==2019.1 pyexcel-webio==0.1.4 pyexcel-io==0.5.17 django-excel==0.0.10 From 66f12d4b4655e7b52d804e75f87ced8faa34a273 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 16 Apr 2019 08:55:23 +0200 Subject: [PATCH 629/980] Update psycopg2-binary from 2.8.1 to 2.8.2 (#436) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 58ab4d1d8..93fcff449 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.8 djangorestframework==3.9.2 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 -psycopg2-binary==2.8.1 +psycopg2-binary==2.8.2 pytz==2019.1 pyexcel-webio==0.1.4 pyexcel-io==0.5.17 From bb50ef7574105d9d03ec4ed4b3730dae57fb4f65 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 18 Apr 2019 07:59:30 +0200 Subject: [PATCH 630/980] Update pytest-mock from 1.10.3 to 1.10.4 (#438) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 58c984fdc..7d608c372 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -17,5 +17,5 @@ pytest-cov==2.6.1 pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 -pytest-mock==1.10.3 +pytest-mock==1.10.4 pytest-randomly==3.0.0 From 0feb98e2f50b82ad34f8fea3b5012e14f912be39 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 1 May 2019 08:28:04 +0200 Subject: [PATCH 631/980] Update djangorestframework from 3.9.2 to 3.9.3 (#439) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 93fcff449..9edf40cc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==1.11.20 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 django-multiselectfield==0.1.8 -djangorestframework==3.9.2 +djangorestframework==3.9.3 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.8.2 From a62724f2d42554f404b13dc94c2e7a29fbd177fb Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 13 May 2019 08:13:39 +0200 Subject: [PATCH 632/980] Update pytest-cov from 2.6.1 to 2.7.1 (#441) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7d608c372..b70e34e9b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.12 isort==4.3.17 mockldap==0.3.0 pytest==4.4.1 -pytest-cov==2.6.1 +pytest-cov==2.7.1 pytest-django==3.4.8 pytest-env==0.6.2 pytest-freezegun==0.3.0.post1 From e46ec5a185924ea9746cd9f2be5725076c2e43a7 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 13 May 2019 08:18:20 +0200 Subject: [PATCH 633/980] Update factory-boy from 2.11.1 to 2.12.0 (#444) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b70e34e9b..f47e941cc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ -r requirements.txt black==19.3b0 coverage==4.5.3 -factory-boy==2.11.1 +factory-boy==2.12.0 flake8==3.7.7 flake8-blind-except==0.1.1 flake8-debugger==3.1.0 From 10248c83d6a3a6c5eb2e529ce13dac07b261f52a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 13 May 2019 08:22:15 +0200 Subject: [PATCH 634/980] Update pytest from 4.4.1 to 4.5.0 (#445) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f47e941cc..faea4c600 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.2.3 ipdb==0.12 isort==4.3.17 mockldap==0.3.0 -pytest==4.4.1 +pytest==4.5.0 pytest-cov==2.7.1 pytest-django==3.4.8 pytest-env==0.6.2 From ae0fd23a8b944e1d0042ec425812020d47314879 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 13 May 2019 08:32:17 +0200 Subject: [PATCH 635/980] Update djangorestframework from 3.9.3 to 3.9.4 (#443) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9edf40cc2..62cd838d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==1.11.20 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 django-multiselectfield==0.1.8 -djangorestframework==3.9.3 +djangorestframework==3.9.4 djangorestframework-jwt==1.11.0 djangorestframework-jsonapi==2.7.0 psycopg2-binary==2.8.2 From 79df6d1020935e36677747f80bbbaacb9110cc83 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 13 May 2019 08:44:50 +0200 Subject: [PATCH 636/980] Update isort from 4.3.17 to 4.3.19 (#446) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index faea4c600..311cc2f13 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.7.0 flake8-string-format==0.2.3 ipdb==0.12 -isort==4.3.17 +isort==4.3.19 mockldap==0.3.0 pytest==4.5.0 pytest-cov==2.7.1 From 69c31ef4cc2d7ad1dcf59d7ad1896547374a5c6a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 15 May 2019 08:04:57 +0200 Subject: [PATCH 637/980] Update isort from 4.3.19 to 4.3.20 (#447) --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 311cc2f13..a824a68bb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.3.0 flake8-isort==2.7.0 flake8-string-format==0.2.3 ipdb==0.12 -isort==4.3.19 +isort==4.3.20 mockldap==0.3.0 pytest==4.5.0 pytest-cov==2.7.1 From 4ae4feaed34016e0cdf4e0640a706ca75bd78840 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 26 Jun 2019 08:15:34 +0200 Subject: [PATCH 638/980] Update django from 1.11.20 to 1.11.21 (#451) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 62cd838d3..a096ef306 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.0 -django==1.11.20 # pyup: >=1.11,<1.12 +django==1.11.21 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 django-multiselectfield==0.1.8 From 482eeb716f5a6c18ae0bd6c60f61a6469fe39382 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 29 Jul 2019 16:05:22 +0200 Subject: [PATCH 639/980] Update django from 1.11.21 to 1.11.22 (#461) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a096ef306..1fb594272 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.0 -django==1.11.21 # pyup: >=1.11,<1.12 +django==1.11.22 # pyup: >=1.11,<1.12 django-auth-ldap==1.7.0 django-filter==2.1.0 django-multiselectfield==0.1.8 From 60dc74bc73fb687e8a57d13cd5a0791e46bfdbc8 Mon Sep 17 00:00:00 2001 From: Christian Affolter Date: Mon, 5 Aug 2019 13:04:10 +0200 Subject: [PATCH 640/980] Fix contributing guidelines link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92b2a407c..6ec2c6226 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ according to type. ## Contributing -Look at our [contributing guidelines](CONTRIBUTION.md) to start with your first contribution. +Look at our [contributing guidelines](CONTRIBUTING.md) to start with your first contribution. ## License From c0a1dd43253a8f83ee52d15848da99495724be03 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 7 Feb 2020 11:39:22 +0100 Subject: [PATCH 641/980] chore(deps): upgrade requirements-dev --- README.md | 8 +++++ requirements-dev.txt | 31 ++++++++++--------- .../management/commands/redmine_report.py | 4 ++- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 92b2a407c..d364e391b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ You can now access the API at http://localhost:8000/api/v1 and the admin interfa For end user interface have a look at our [Timed Frontend](https://github.com/adfinis-sygroup/timed-frontend) project. +## Development + +To get the application working locally for development, make sure to create a file `.env` with the following content: + +``` +ENV=dev +``` + ## Configuration Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) diff --git a/requirements-dev.txt b/requirements-dev.txt index a824a68bb..0883cbc38 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,21 +1,22 @@ -r requirements.txt -black==19.3b0 -coverage==4.5.3 +black==19.10b0 +coverage==5.0.3 factory-boy==2.12.0 -flake8==3.7.7 +flake8==3.7.9 flake8-blind-except==0.1.1 -flake8-debugger==3.1.0 +flake8-debugger==3.2.1 flake8-deprecated==1.3 -flake8-docstrings==1.3.0 -flake8-isort==2.7.0 +flake8-docstrings==1.5.0 +flake8-isort==2.8.0 flake8-string-format==0.2.3 -ipdb==0.12 -isort==4.3.20 -mockldap==0.3.0 -pytest==4.5.0 -pytest-cov==2.7.1 -pytest-django==3.4.8 +ipdb==0.12.3 +isort==4.3.21 +mockldap==0.3.0.post1 +pdbpp==0.10.2 +pytest==5.3.5 +pytest-cov==2.8.1 +pytest-django==3.8.0 pytest-env==0.6.2 -pytest-freezegun==0.3.0.post1 -pytest-mock==1.10.4 -pytest-randomly==3.0.0 +pytest-freezegun==0.4.1 +pytest-mock==2.0.0 +pytest-randomly==3.2.1 diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 3ec199800..1f93a45ac 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -59,7 +59,9 @@ def handle(self, *args, **options): for project in projects: estimated_hours = ( - project.estimated_time and project.estimated_time.total_seconds() / 3600 + project.estimated_time.total_seconds() / 3600 + if project.estimated_time + else 0.0 ) total_hours = project.total_hours.total_seconds() / 3600 try: From 413fa081176ddde5d1aab0dcb96545400a9a55f2 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 7 Feb 2020 14:39:49 +0100 Subject: [PATCH 642/980] chore(deps): upgrade to django 2, python 3.8 and everything else Upgrade from django 1 to 2, along with other dependencies. Only minor changes were required, including that monthly reports use a constructed primary key that now has the format "YYYYMM" instead of "YYYY-M". --- Dockerfile | 2 +- requirements.txt | 24 +++++++++---------- timed/projects/views.py | 4 ++-- .../management/commands/redmine_report.py | 6 +++-- timed/reports/tests/test_month_statistic.py | 4 ++-- .../test_notify_supervisors_shorttime.py | 2 +- timed/reports/views.py | 6 ++--- 7 files changed, 25 insertions(+), 23 deletions(-) diff --git a/Dockerfile b/Dockerfile index 65bda76fe..fd152afc3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6 +FROM python:3.8 WORKDIR /app diff --git a/requirements.txt b/requirements.txt index 1fb594272..080920953 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,20 @@ -python-dateutil==2.8.0 -django==1.11.22 # pyup: >=1.11,<1.12 -django-auth-ldap==1.7.0 -django-filter==2.1.0 -django-multiselectfield==0.1.8 -djangorestframework==3.9.4 +python-dateutil==2.8.1 +django==2.2.10 # pyup: <3.0 +django-auth-ldap==2.1.0 +django-filter==2.2.0 +django-multiselectfield==0.1.11 +djangorestframework==3.11.0 djangorestframework-jwt==1.11.0 -djangorestframework-jsonapi==2.7.0 -psycopg2-binary==2.8.2 -pytz==2019.1 +djangorestframework-jsonapi==3.0.0 +psycopg2-binary==2.8.4 +pytz==2019.3 pyexcel-webio==0.1.4 -pyexcel-io==0.5.17 +pyexcel-io==0.5.20 django-excel==0.0.10 pyexcel-ods3==0.5.3 -pyexcel-xlsx==0.5.7 +pyexcel-xlsx==0.5.8 pyexcel-ezodf==0.3.4 django-environ==0.4.5 -django-money==0.14.4 +django-money==1.0 python-redmine==2.2.1 uwsgi==2.0.18 diff --git a/timed/projects/views.py b/timed/projects/views.py index 9eb1d9b98..7f9d5c409 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,7 +1,7 @@ """Viewsets for the projects app.""" from rest_framework.viewsets import ReadOnlyModelViewSet -from rest_framework_json_api.views import PrefetchForIncludesHelperMixin +from rest_framework_json_api.views import PreloadIncludesMixin from timed.projects import filters, models, serializers @@ -38,7 +38,7 @@ def get_queryset(self): return models.CostCenter.objects.all() -class ProjectViewSet(PrefetchForIncludesHelperMixin, ReadOnlyModelViewSet): +class ProjectViewSet(PreloadIncludesMixin, ReadOnlyModelViewSet): """Project view set.""" serializer_class = serializers.ProjectSerializer diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 1f93a45ac..4257a430b 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -53,8 +53,10 @@ def handle(self, *args, **options): .values("id") ) # calculate total hours - projects = Project.objects.filter(id__in=affected_projects).annotate( - total_hours=Sum("tasks__reports__duration") + projects = ( + Project.objects.filter(id__in=affected_projects) + .order_by("name") + .annotate(total_hours=Sum("tasks__reports__duration")) ) for project in projects: diff --git a/timed/reports/tests/test_month_statistic.py b/timed/reports/tests/test_month_statistic.py index 28fc5311c..9643af6cd 100644 --- a/timed/reports/tests/test_month_statistic.py +++ b/timed/reports/tests/test_month_statistic.py @@ -18,12 +18,12 @@ def test_month_statistic_list(auth_client): expected_json = [ { "type": "month-statistics", - "id": "2015-12", + "id": "201512", "attributes": {"year": 2015, "month": 12, "duration": "03:00:00"}, }, { "type": "month-statistics", - "id": "2016-1", + "id": "201601", "attributes": {"year": 2016, "month": 1, "duration": "01:00:00"}, }, ] diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py index be64eca21..e7693d585 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -39,7 +39,7 @@ def test_notify_supervisors(db, mailoutbox): mail = mailoutbox[0] assert mail.to == [supervisor.email] body = mail.body - assert "Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9" in body + assert "Time range: July 17, 2017 - July 23, 2017\nRatio: 0.9" in body expected = ("{0} 35.0/42.5 (Ratio 0.82 Delta -7.5 Balance -9.0)").format( supervisee.get_full_name() ) diff --git a/timed/reports/views.py b/timed/reports/views.py index 603edf9d7..e90b09baa 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -5,8 +5,8 @@ from zipfile import ZipFile from django.conf import settings -from django.db.models import F, Sum, Value -from django.db.models.functions import Concat, ExtractMonth, ExtractYear +from django.db.models import F, Sum +from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponse, HttpResponseBadRequest from ezodf import Cell, opendoc from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet @@ -49,7 +49,7 @@ def get_queryset(self): ) queryset = queryset.values("year", "month") queryset = queryset.annotate(duration=Sum("duration")) - queryset = queryset.annotate(pk=Concat("year", Value("-"), "month")) + queryset = queryset.annotate(pk=F("year") * 100 + F("month")) return queryset From 1fa7e96969446d2f421fc9c247b2228726d1576d Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 7 Feb 2020 14:48:36 +0100 Subject: [PATCH 643/980] chore(deps): switch to djangorestframework-simplejwt Drop djangorestframework-jwt in favour of -simplejwt as it's no longer maintained. See: https://github.com/jpadilla/django-rest-framework-jwt/issues/484 --- requirements.txt | 2 +- timed/settings.py | 13 ++++++------- timed/tests/client.py | 15 +++++---------- timed/urls.py | 6 +++--- 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/requirements.txt b/requirements.txt index 080920953..220940e48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ django-auth-ldap==2.1.0 django-filter==2.2.0 django-multiselectfield==0.1.11 djangorestframework==3.11.0 -djangorestframework-jwt==1.11.0 +djangorestframework-simplejwt==4.4.0 djangorestframework-jsonapi==3.0.0 psycopg2-binary==2.8.4 pytz==2019.3 diff --git a/timed/settings.py b/timed/settings.py index 3bf154ea1..c2af1a5c6 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -153,8 +153,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DEFAULT_PARSER_CLASSES": ("rest_framework_json_api.parsers.JSONParser",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_jwt.authentication.JSONWebTokenAuthentication", - "rest_framework.authentication.SessionAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", @@ -187,11 +186,11 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): AUTH_USER_MODEL = "employment.User" -JWT_AUTH = { - "JWT_EXPIRATION_DELTA": datetime.timedelta(days=2), - "JWT_ALLOW_REFRESH": True, - "JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=7), - "JWT_AUTH_HEADER_PREFIX": "Bearer", +SIMPLE_AUTH = { + "ACCESS_TOKEN_LIFETIME": datetime.timedelta(days=2), + "REFRESH_TOKEN_LIFETIME": datetime.timedelta(days=7), + # TODO check if this is ROTATE_REFRESH_TOKENS + # "JWT_ALLOW_REFRESH": True, } AUTH_PASSWORD_VALIDATORS = [ diff --git a/timed/tests/client.py b/timed/tests/client.py index 9c0bcc2a6..eedcf4837 100644 --- a/timed/tests/client.py +++ b/timed/tests/client.py @@ -5,7 +5,6 @@ from django.urls import reverse from rest_framework import exceptions, status from rest_framework.test import APIClient -from rest_framework_jwt.settings import api_settings class JSONAPIClient(APIClient): @@ -40,7 +39,7 @@ def post(self, path, data=None, **kwargs): path=path, data=self._parse_data(data), content_type=self._content_type, - **kwargs + **kwargs, ) def delete(self, path, data=None, **kwargs): @@ -53,7 +52,7 @@ def delete(self, path, data=None, **kwargs): path=path, data=self._parse_data(data), content_type=self._content_type, - **kwargs + **kwargs, ) def patch(self, path, data=None, **kwargs): @@ -66,7 +65,7 @@ def patch(self, path, data=None, **kwargs): path=path, data=self._parse_data(data), content_type=self._content_type, - **kwargs + **kwargs, ) def login(self, username, password): @@ -79,7 +78,7 @@ def login(self, username, password): data = { "data": { "attributes": {"username": username, "password": password}, - "type": "obtain-json-web-tokens", + "type": "token-obtain-pair-views", } } @@ -88,8 +87,4 @@ def login(self, username, password): if response.status_code != status.HTTP_200_OK: raise exceptions.AuthenticationFailed() - self.credentials( - HTTP_AUTHORIZATION="{0} {1}".format( - api_settings.JWT_AUTH_HEADER_PREFIX, response.data["token"] - ) - ) + self.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access']}") diff --git a/timed/urls.py b/timed/urls.py index 4277f1b8a..bc0f76d94 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -2,12 +2,12 @@ from django.conf.urls import include, url from django.contrib import admin -from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ url(r"^admin/", admin.site.urls), - url(r"^api/v1/auth/login", obtain_jwt_token, name="login"), - url(r"^api/v1/auth/refresh", refresh_jwt_token, name="refresh"), + url(r"^api/v1/auth/login", TokenObtainPairView.as_view(), name="login"), + url(r"^api/v1/auth/refresh", TokenRefreshView.as_view(), name="refresh"), url(r"^api/v1/", include("timed.employment.urls")), url(r"^api/v1/", include("timed.projects.urls")), url(r"^api/v1/", include("timed.tracking.urls")), From 68f96a6a95830562c720698e89aafead99894c01 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 20 Feb 2020 10:46:12 +0100 Subject: [PATCH 644/980] feat(projects): make tasks modifiable for reviewers Any user that is a reviewer in at least on project is allowed to create, update and delete tasks. Some drive-by improvements for the testing setup using pytest-factoryboy. --- .isort.cfg | 1 + requirements-dev.txt | 1 + requirements.txt | 3 +- timed/conftest.py | 20 ++++++++ timed/permissions.py | 11 ++++ timed/projects/tests/test_task.py | 85 +++++++++++++++++++------------ timed/projects/views.py | 22 ++++---- 7 files changed, 98 insertions(+), 45 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index 4ca818cf1..3a892b0b8 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,6 +1,7 @@ [settings] skip=migrations,snapshots known_first_party=timed +known_third_party=pytest_factoryboy multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 diff --git a/requirements-dev.txt b/requirements-dev.txt index 0883cbc38..036af8acf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -17,6 +17,7 @@ pytest==5.3.5 pytest-cov==2.8.1 pytest-django==3.8.0 pytest-env==0.6.2 +pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 pytest-mock==2.0.0 pytest-randomly==3.2.1 diff --git a/requirements.txt b/requirements.txt index 220940e48..7e8119f2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,10 @@ python-dateutil==2.8.1 django==2.2.10 # pyup: <3.0 django-auth-ldap==2.1.0 -django-filter==2.2.0 django-multiselectfield==0.1.11 djangorestframework==3.11.0 djangorestframework-simplejwt==4.4.0 -djangorestframework-jsonapi==3.0.0 +djangorestframework-jsonapi[django-filter]==3.0.0 psycopg2-binary==2.8.4 pytz==2019.3 pyexcel-webio==0.1.4 diff --git a/timed/conftest.py b/timed/conftest.py index b538ba703..8657d3236 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -1,8 +1,28 @@ +import inspect + import mockldap import pytest from django.contrib.auth import get_user_model +from factory.base import FactoryMetaClass +from pytest_factoryboy import register +from timed.employment import factories as employment_factories +from timed.projects import factories as projects_factories +from timed.subscription import factories as subscription_factories from timed.tests.client import JSONAPIClient +from timed.tracking import factories as tracking_factories + + +def register_module(module): + for name, obj in inspect.getmembers(module): + if isinstance(obj, FactoryMetaClass) and not obj._meta.abstract: + register(obj) + + +register_module(employment_factories) +register_module(projects_factories) +register_module(subscription_factories) +register_module(tracking_factories) @pytest.fixture(autouse=True, scope="session") diff --git a/timed/permissions.py b/timed/permissions.py index f1bd48364..ef61b25a3 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -1,5 +1,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated +from timed.projects import models as projects_models + class IsUnverified(BasePermission): """Allows access only to verified objects.""" @@ -87,8 +89,17 @@ def has_object_permission(self, request, view, obj): class IsReviewer(IsAuthenticated): """Allows access to object only to reviewers.""" + def has_permission(self, request, view): + if request.method not in SAFE_METHODS: + return request.user.reviews.exists() + return True + def has_object_permission(self, request, view, obj): user = request.user + + if isinstance(obj, projects_models.Task): + return obj.project.reviewers.filter(id=user.id).exists() + return obj.task.project.reviewers.filter(id=user.id).exists() diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index f7bcd6669..3a4e6c7ba 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -1,16 +1,14 @@ """Tests for the tasks endpoint.""" from datetime import date, timedelta +import pytest from django.urls import reverse from rest_framework import status -from timed.projects.factories import TaskFactory -from timed.tracking.factories import ReportFactory - -def test_task_list_not_archived(auth_client): - task = TaskFactory.create(archived=False) - TaskFactory.create(archived=True) +def test_task_list_not_archived(auth_client, task_factory): + task = task_factory(archived=False) + task_factory(archived=True) url = reverse("task-list") response = auth_client.get(url, data={"archived": 0}) @@ -21,27 +19,27 @@ def test_task_list_not_archived(auth_client): assert json["data"][0]["id"] == str(task.id) -def test_task_my_most_frequent(auth_client): +def test_task_my_most_frequent(auth_client, task_factory, report_factory): user = auth_client.user - tasks = TaskFactory.create_batch(6) + tasks = task_factory.create_batch(6) report_date = date.today() - timedelta(days=20) old_report_date = date.today() - timedelta(days=90) # tasks[0] should appear as most frequently used task - ReportFactory.create_batch(5, date=report_date, user=user, task=tasks[0]) + report_factory.create_batch(5, date=report_date, user=user, task=tasks[0]) # tasks[1] should appear as secondly most frequently used task - ReportFactory.create_batch(4, date=report_date, user=user, task=tasks[1]) + report_factory.create_batch(4, date=report_date, user=user, task=tasks[1]) # tasks[2] should not appear in result, as too far in the past - ReportFactory.create_batch(4, date=old_report_date, user=user, task=tasks[2]) + report_factory.create_batch(4, date=old_report_date, user=user, task=tasks[2]) # tasks[3] should not appear in result, as project is archived tasks[3].project.archived = True tasks[3].project.save() - ReportFactory.create_batch(4, date=report_date, user=user, task=tasks[3]) + report_factory.create_batch(4, date=report_date, user=user, task=tasks[3]) # tasks[4] should not appear in result, as task is archived tasks[4].archived = True tasks[4].save() - ReportFactory.create_batch(4, date=report_date, user=user, task=tasks[4]) + report_factory.create_batch(4, date=report_date, user=user, task=tasks[4]) url = reverse("task-list") @@ -54,43 +52,65 @@ def test_task_my_most_frequent(auth_client): assert data[1]["id"] == str(tasks[1].id) -def test_task_detail(auth_client): - task = TaskFactory.create() - +def test_task_detail(auth_client, task): url = reverse("task-detail", args=[task.id]) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK -def test_task_create(auth_client): +@pytest.mark.parametrize( + "is_reviewer,expected", + [(True, status.HTTP_201_CREATED), (False, status.HTTP_403_FORBIDDEN)], +) +def test_task_create(auth_client, project, is_reviewer, expected): url = reverse("task-list") - response = auth_client.post(url) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - - -def test_task_update(auth_client): - task = TaskFactory.create() + if is_reviewer: + project.reviewers.add(auth_client.user) + + data = { + "data": { + "attributes": {"name": "test task"}, + "relationships": { + "project": {"data": {"type": "projects", "id": project.pk}} + }, + "type": "tasks", + } + } + response = auth_client.post(url, data=data) + assert response.status_code == expected + + +@pytest.mark.parametrize( + "is_reviewer,expected", + [(True, status.HTTP_200_OK), (False, status.HTTP_403_FORBIDDEN)], +) +def test_task_update(auth_client, project, task, is_reviewer, expected): + if is_reviewer: + project.reviewers.add(auth_client.user) url = reverse("task-detail", args=[task.id]) response = auth_client.patch(url) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert response.status_code == expected -def test_task_delete(auth_client): - task = TaskFactory.create() +@pytest.mark.parametrize( + "is_reviewer,expected", + [(True, status.HTTP_204_NO_CONTENT), (False, status.HTTP_403_FORBIDDEN)], +) +def test_task_delete(auth_client, project, task, is_reviewer, expected): + if is_reviewer: + project.reviewers.add(auth_client.user) url = reverse("task-detail", args=[task.id]) response = auth_client.delete(url) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - + assert response.status_code == expected -def test_task_detail_no_reports(auth_client): - task = TaskFactory.create() +def test_task_detail_no_reports(auth_client, task): url = reverse("task-detail", args=[task.id]) res = auth_client.get(url) @@ -101,9 +121,8 @@ def test_task_detail_no_reports(auth_client): assert json["meta"]["spent-time"] == "00:00:00" -def test_task_detail_with_reports(auth_client): - task = TaskFactory.create() - ReportFactory.create_batch(5, task=task, duration=timedelta(minutes=30)) +def test_task_detail_with_reports(auth_client, task, report_factory): + report_factory.create_batch(5, task=task, duration=timedelta(minutes=30)) url = reverse("task-detail", args=[task.id]) diff --git a/timed/projects/views.py b/timed/projects/views.py index 7f9d5c409..8c4704fda 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,8 +1,9 @@ """Viewsets for the projects app.""" -from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework_json_api.views import PreloadIncludesMixin +from timed.permissions import IsAuthenticated, IsReadOnly, IsReviewer, IsSuperUser from timed.projects import filters, models, serializers @@ -57,21 +58,22 @@ def get_queryset(self): return queryset.select_related("customer", "billing_type", "cost_center") -class TaskViewSet(ReadOnlyModelViewSet): +class TaskViewSet(ModelViewSet): """Task view set.""" serializer_class = serializers.TaskSerializer filterset_class = filters.TaskFilterSet + queryset = models.Task.objects.select_related("project", "cost_center") + permission_classes = [ + # superuser may edit all tasks + IsSuperUser + # reviewer may edit all tasks + | IsReviewer + # all authenticated users may read all tasks + | IsAuthenticated & IsReadOnly + ] ordering = "name" - def get_queryset(self): - """Prefetch related data. - - :return: The tasks - :rtype: QuerySet - """ - return models.Task.objects.select_related("project", "cost_center") - def filter_queryset(self, queryset): """Specific filter queryset options.""" # my most frequent filter uses LIMIT so default ordering From 9f4c46c666718cf624e230914fd26b1860ac311a Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 21 Feb 2020 16:19:56 +0100 Subject: [PATCH 645/980] chore: update dependencies, add snapshottest --- requirements-dev.txt | 3 ++- requirements.txt | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 036af8acf..1c6bcb71a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ flake8-debugger==3.2.1 flake8-deprecated==1.3 flake8-docstrings==1.5.0 flake8-isort==2.8.0 -flake8-string-format==0.2.3 +flake8-string-format==0.3.0 ipdb==0.12.3 isort==4.3.21 mockldap==0.3.0.post1 @@ -21,3 +21,4 @@ pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 pytest-mock==2.0.0 pytest-randomly==3.2.1 +snapshottest==0.5.1 diff --git a/requirements.txt b/requirements.txt index 7e8119f2e..c86e215e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,12 @@ python-dateutil==2.8.1 django==2.2.10 # pyup: <3.0 django-auth-ldap==2.1.0 -django-multiselectfield==0.1.11 +# might remove this once we find out how the jsonapi extras_require work +django-filter==2.2.0 +django-multiselectfield==0.1.12 djangorestframework==3.11.0 djangorestframework-simplejwt==4.4.0 -djangorestframework-jsonapi[django-filter]==3.0.0 +djangorestframework-jsonapi[django-filter]==3.1.0 psycopg2-binary==2.8.4 pytz==2019.3 pyexcel-webio==0.1.4 From c8b41c9e5560183dbc795498d670c33c5f1200a2 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 21 Feb 2020 12:51:32 +0100 Subject: [PATCH 646/980] chore: drive-by cleanup unneeded bits in settings, apps init, etc Drop unused "default" TEMPLATE backend, making "text" default. Simplify template loading and remove duplicated validation code. Also move the workreports.ots into "templates" directory (might need deployment config change). Remove unnecessary "default_app_config" definition in apps __init__.py. --- timed/projects/__init__.py | 3 --- .../management/commands/redmine_report.py | 10 ++++----- .../commands/notify_changed_employments.py | 10 ++++----- .../commands/notify_reviewers_unverified.py | 10 ++++----- .../commands/notify_supervisors_shorttime.py | 10 ++++----- timed/reports/templates/workreport.ots | Bin 0 -> 12793 bytes timed/settings.py | 18 ++--------------- timed/tracking/__init__.py | 3 --- timed/tracking/serializers.py | 19 ++++++++---------- 9 files changed, 29 insertions(+), 54 deletions(-) create mode 100644 timed/reports/templates/workreport.ots diff --git a/timed/projects/__init__.py b/timed/projects/__init__.py index 1f93e5943..e69de29bb 100644 --- a/timed/projects/__init__.py +++ b/timed/projects/__init__.py @@ -1,3 +0,0 @@ -# noqa: D104 - -default_app_config = "timed.projects.apps.ProjectsConfig" diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 4257a430b..7f7858a95 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -5,12 +5,14 @@ from django.conf import settings from django.core.management.base import BaseCommand from django.db.models import Count, Sum -from django.template.loader import render_to_string +from django.template.loader import get_template from django.utils import timezone from timed.projects.models import Project from timed.tracking.models import Report +template = get_template("redmine/weekly_report.txt") + class Command(BaseCommand): help = "Update associated Redmine projects and send reports to watchers." @@ -73,8 +75,7 @@ def handle(self, *args, **options): ).order_by("date") hours = reports.aggregate(hours=Sum("duration"))["hours"] - issue.notes = render_to_string( - "redmine/weekly_report.txt", + issue.notes = template.render( { "project": project, "hours": hours.total_seconds() / 3600, @@ -82,8 +83,7 @@ def handle(self, *args, **options): "total_hours": total_hours, "estimated_hours": estimated_hours, "reports": reports, - }, - using="text", + } ) issue.custom_fields = [ {"id": settings.REDMINE_SPENTHOURS_FIELD, "value": total_hours} diff --git a/timed/reports/management/commands/notify_changed_employments.py b/timed/reports/management/commands/notify_changed_employments.py index e561d415a..1712d7411 100644 --- a/timed/reports/management/commands/notify_changed_employments.py +++ b/timed/reports/management/commands/notify_changed_employments.py @@ -3,11 +3,13 @@ from django.conf import settings from django.core.mail import EmailMessage from django.core.management.base import BaseCommand -from django.template.loader import render_to_string +from django.template.loader import get_template from django.utils import timezone from timed.employment.models import Employment +template = get_template("mail/notify_changed_employments.txt") + class Command(BaseCommand): """ @@ -46,11 +48,7 @@ def handle(self, *args, **options): if employments.exists(): from_email = settings.DEFAULT_FROM_EMAIL subject = "[Timed] Employments changed in last {0} days".format(last_days) - body = render_to_string( - "mail/notify_changed_employments.txt", - {"employments": employments}, - using="text", - ) + body = template.render({"employments": employments},) message = EmailMessage( subject=subject, body=body, from_email=from_email, to=[email] ) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index 6b98339ce..b073b2d42 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -6,10 +6,12 @@ from django.core.mail import EmailMessage, get_connection from django.core.management.base import BaseCommand from django.db.models import Count -from django.template.loader import render_to_string +from django.template.loader import get_template from timed.tracking.models import Report +template = get_template("mail/notify_reviewers_unverified.txt") + class Command(BaseCommand): """ @@ -103,8 +105,7 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): for reviewer in reviewers: if reports.filter(task__project__reviewers=reviewer).exists(): - body = render_to_string( - "mail/notify_reviewers_unverified.txt", + body = template.render( { # we need start and end date in system format "start": str(start), @@ -113,8 +114,7 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): "reviewer": reviewer, "protocol": settings.HOST_PROTOCOL, "domain": settings.HOST_DOMAIN, - }, - using="text", + } ) message = EmailMessage( diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index 83e93f914..bb5d6fea7 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -4,7 +4,9 @@ from django.contrib.auth import get_user_model from django.core.mail import send_mass_mail from django.core.management.base import BaseCommand -from django.template.loader import render_to_string +from django.template.loader import get_template + +template = get_template("mail/notify_supervisor_shorttime.txt") class Command(BaseCommand): @@ -122,15 +124,13 @@ def _notify_supervisors(self, start, end, ratio, supervisees): (suspect, supervisees[suspect.id]) for suspect in suspects ] if suspects.count() > 0 and supervisor.email: - body = render_to_string( - "mail/notify_supervisor_shorttime.txt", + body = template.render( { "start": start, "end": end, "ratio": ratio, "suspects": suspects_shorttime, - }, - using="text", + } ) mails.append((subject, body, from_email, [supervisor.email])) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots new file mode 100644 index 0000000000000000000000000000000000000000..a205f11d5760930d0006f37b93dbed2ecde6026a GIT binary patch literal 12793 zcmb7q1z23mvNaCD-6g@@-Q6v?yE_bSL4t(f?oM!b5AGV=-6goggKNn-@0|PozvkOB zz4y#ov%71itGZUpOM!x+0s%n*0pWT@$#}%)$g%+e0lgismw>Fzt&JVs?2Ps8?5xZU z^&QP^ZRnhBjA(830p zR5GUXw+KK$ZwKZpxssWawSkSkxfOuU@!w5aI~&t4^0H!Z(AdzgAAyq)7gl(E-F!V> zpg>=rnT4a$fq+1PBCh|NNT%0_|1 zL5jgkg-b?CMoP^}#mLD13wuVe-+1cEj?u&Q;nSiRozony>qpLD^0zV%>z@- z!wb!0>#dXP-%H}VDpLEa(|c-iI%^Ain#u<1vPT+nhFVMdS}Vufin=;lMmlSTx>{zs z%6@cL{}^bR@2y+2n!9GV>NnwaXDSR9_18v3y?J<>h@V_<1% zv1@9xbLOCLe!FjOe_&>Fcz$be`CxW=b9r-aWbI&N<9cCZZ+`!3_2<>*boctq;Pzbi z*4)tkZ1>@O@8ROW`oiSK%EI32^x@jb{^s1p#@PAZ{Po$&+S=O2&cW{9_U8Wa!OrIK z{?_5);pWlB`pNyl@!9^x(cZ<~#qsvd&)uuDqr3C%yUW9;+nuNT!?Uxq%j>(#yT`lh z^M||3=jZ1O57HPQAlPaNVF4xAh2v#7c@$NAAiIU;YmLJiQxWT9$Z-a=kV~mj0vzUV+;O5DO5^x`vF(UyaZV6a`;)Xcgn z+dr>7+ay}H#+8k};pn+-PAG+iQA}+ybavKIOPpXL_4bVAqVrsU+Wh`iUmx3*TK`hW zr5p;K=&66E#pssYNb{xDc(n3U8d}aL*C9aiW8&A%cugF+K&z}F9G&znf*dSNOQisX z!T#E;wr|bLW25=1ka8UOP+u@VB{W~v+l(dF@7ebjP8@W8;?5uV>U)DCMv?}*@4!o( zbQk+ci|46;^b_))pei4@b^E@z3TSc0JM7I3;_9o3DxWi*r03DJ;`FKzUeiJQY$2l+ zuLPE@r4FtGQ(iZHEzyh9x?R3;-ot%u7dA>Vg@^v`icdT>$Xm|lN?iHl*l|^2-xQb(2(%QxV zSa};$Mk%;n4l`jQ?1^Ae=4_-evzBjLELgCCDR>CW>D%ILRTJb+T*OmyWFHM!vB6oR z7L(xiiP==G&}g>X#e&1jvgVAX!)QSBVbSqY5IUFAWS_qV>_gAQZ6=s=r5YgO8m8`x z-QO*p=&&7zo(PoDRMO9MV8XTJJvsQ1Z-jINrpAjqzJ{miR(DO#ftGcc7|0C)@PQp0@t>%QWw6WK7M<< zzncRDMZmFy4i!&l*CgLQV&~Elrdho5yk88uE}m#a@ffuU@^-K+LfsNf8Ci+kAq?1% zqeo$LO&w?weD&_pi?$BC(o5Ko9?N6idJ0LnOAoX;%nyLE18enu+QQ=^--_j%q_9JN ziJ;U1H}6dRX2NB*P`TU8*ShNZGFmHU6337$TP2$6g?Wp|hnRZX`~Ve71g#VYD zOE^e?Jl}*~Rv|>3c591EgG`2|2HCa5*B!qO)>{y3K0Hb)Fqm+k_cimdsVq>>S{&wp zt&nOV72;Q(v6ED?Xk)U_o;0`q8K0x2%^bPmkvG1BieLvN5M^*I5BvW3-g!W(qA+lG zSKu@WRc#A?rHRv#psIWJaLt*~9{#dMJ7jyZDcsaf-&zKG1RUlLI~Tl?L53I!Z}1#N zI6i1Gi-9le_RFehP+D3qvjpcU zq{f(=Gd8$Q!Q>WgoT^P;UfiOi`scPf690J0IV0(~VAs<=G�`a4O2s%APMcTZh5^ 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

s0BQ3_`3QRs zdM;7{)1=VaFa;lIE5r=v98#t{N_QQ=2Pfbd5{$4rzG?ybUjbI1CAh9#yBfVsm`gg9d))4v_iZHI2@afrgn9k6mxC zod-2^bch~$&(8e@8ai0n20D^P4N(m-Du%NKrehCu6L_cc6d|0{xp{7|q>CePY3mVi zAQ_utGYI&+6&~@?LBn8Dw(AOSMv!jg&pXn$a`kSl1LT*@;PI77%Z% zuqZ!&LE|Sk5~KiX?K(C5fFo{3BbZ=d({W7oOfDTyyBZG;Ci@jC>3s_ENLtdfBY&(@ zg9tx!F6kPGjmIACtqbPez+GX))Ggm0+dg|$<=$4_gCLCur|d>VCd`PNn+lS zH6KR4I22(@rCpO$HRRcVA|h;noQTl-Z%)G=r8CoSi{$#lzd&P6aDe0zpvZOWiK)87 ziiQEvuMGg1jnf-M=J#>BdliTw>4dTy>G}Zm)%QE=I$v6fZD?krXvdcRefZGUs* z%xHy07w+{iT{-flTe;bt`9g3$5RvJprt1!GA!wuAnIO^m)!qP4u~_)>bdx`MTo~U1 z=?NdZ@8Ze2XaR)Io|ugpgiomMV?3ojghqW7v5T(b%5=`On;?pZb~|yzCp7t!^TyFvlu3&Xoxa|;cqoW3=CG^7+KqskL?K=Mo%+}nb>gm zbK(V3?dkm1_YCN9_hy7x&a46C*5yllI7O>XQ&;X{1mqgFQ_P}u2H|2Ojc0mhtyu=x zy3y`G>JOJH{nIrW&Yl#fTsr#>{g-OU+KreakDuA-O24)*OVk_J%X=296npe(U0=>* z4vVxQ3|Q6>@D8sTMX`#!>rB{^(F?(58WBQ4Gl(Lj7dQZc5#b9+DkY|VbW`02MjJs zikhoOE~VT4!%qWB`I&`1XAm$+t?qZrpXdhFUnCpv2;L_8p2juO@>h**mhivDNwn9A zUPeSofJQ=Al(gVZd23Z1vX+U+D7m0- zD;IYf&bh>6{{5r?I$Wdh=pZN^aCKbQt<~MTJ;zcLAWMg+I$+rtn}a*zT%j{by|gyW z4hlg=At^Qd8dl_6X?@3qvSdv#Yw8*&?lfDVCbdOT-=Oe{X03V>AI9t+z~}H_%+e?# zL@sxcKD==^$7Dm0taD%>O)!j7{`Xezemu)Bx{H0o_Z6fy)bf{cWZfF9yl9xE5({?Vx)Z_vD%@abld{|Ns6cH5_jzuCXHVE-BQ=iSu5Alk3ZzuQ~=XO>?mAfPvK@0Wc={NIw@@0#l$`|u0*&%NEh z&b$7C=a&Zi_bh+Bo4dbY`CXI!d!9E{_Lngr{qJ7=QKS8PrZ<)Lms$P=)Bn*htf(vWzs*ReqT$%zs1zk^V93{xkCTiR5kK{$=}Ae>08COM$ Date: Fri, 21 Feb 2020 11:10:33 +0100 Subject: [PATCH 647/980] feat(tracking): send mail if report get edited/moved by reviewer Send mails for both bulk-update and single update. If a bulk-update contains changes for multiple users, each one gets a mail. Mail is only sent if the task or other fields (comment, not_billable, review) are changed. --- timed/tracking/tasks.py | 87 ++++++++++ .../mail/notify_user_changed_reports.tmpl | 16 ++ .../tracking/templatetags/tracking_extras.py | 12 ++ timed/tracking/tests/snapshots/__init__.py | 0 .../tests/snapshots/snap_test_report.py | 65 +++++++ timed/tracking/tests/test_report.py | 159 ++++++++++++++++++ timed/tracking/views.py | 46 +++-- 7 files changed, 369 insertions(+), 16 deletions(-) create mode 100644 timed/tracking/tasks.py create mode 100644 timed/tracking/templates/mail/notify_user_changed_reports.tmpl create mode 100644 timed/tracking/templatetags/tracking_extras.py create mode 100644 timed/tracking/tests/snapshots/__init__.py create mode 100644 timed/tracking/tests/snapshots/snap_test_report.py diff --git a/timed/tracking/tasks.py b/timed/tracking/tasks.py new file mode 100644 index 000000000..401a6e058 --- /dev/null +++ b/timed/tracking/tasks.py @@ -0,0 +1,87 @@ +from django.conf import settings +from django.core.mail import EmailMessage, get_connection +from django.template.loader import get_template + +template = get_template("mail/notify_user_changed_reports.tmpl") + + +def _send_notification_emails(changes, reviewer): + """Send email for each user.""" + + subject = "[Timed] Your reports have been changed" + from_email = settings.DEFAULT_FROM_EMAIL + connection = get_connection() + + messages = [] + + for user_changes in changes: + user = user_changes["user"] + + body = template.render( + { + # we need start and end date in system format + "reviewer": reviewer, + "user_changes": user_changes["changes"], + } + ) + + message = EmailMessage( + subject=subject, + body=body, + from_email=from_email, + to=[user.email], + connection=connection, + ) + + messages.append(message) + if len(messages) > 0: + connection.send_messages(messages) + + +def _get_report_changeset(report, fields): + changeset = { + "report": report, + "changes": { + key: {"old": getattr(report, key), "new": fields[key]} + for key in fields.keys() + # skip if field is not changed or just a reviewer field + if getattr(report, key) != fields[key] + and key not in ["review", "verified_by"] + }, + } + if not changeset["changes"]: + return False + return changeset + + +def notify_user_changed_report(report, fields, reviewer): + changeset = _get_report_changeset(report, fields) + + if not changeset: + return + + user_changes = {"user": report.user, "changes": [changeset]} + _send_notification_emails([user_changes], reviewer) + + +def notify_user_changed_reports(queryset, fields, reviewer): + users = [report.user for report in queryset.order_by("user").distinct("user")] + user_changes = [] + + for user in users: + changes = [] + for report in queryset.filter(user=user).order_by("date"): + changeset = _get_report_changeset(report, fields) + + # skip edits of own reports and empty changes + if report.user == reviewer or not changeset: + continue + changes.append(changeset) + + # skip user if changes are empty + if not changes: + continue + + user_changes.append({"user": user, "changes": changes}) + + _send_notification_emails(user_changes, reviewer) diff --git a/timed/tracking/templates/mail/notify_user_changed_reports.tmpl b/timed/tracking/templates/mail/notify_user_changed_reports.tmpl new file mode 100644 index 000000000..88a0ab34f --- /dev/null +++ b/timed/tracking/templates/mail/notify_user_changed_reports.tmpl @@ -0,0 +1,16 @@ +{% load tracking_extras %} +Some of your reports have been changed. + +Reviewer: {{reviewer.first_name }} {{ reviewer.last_name }} +{% for changeset in user_changes %} + +Date: {{ changeset.report.date|date:"SHORT_DATE_FORMAT" }} +Duration: {{ changeset.report.duration|duration }} +{% if "task" not in changeset.changes %}Task: {{ changeset.report.task }}{% endif %} +{% if "comment" not in changeset.changes %}Comment: {{ changeset.report.comment }}{% endif %} +{% for key, change in changeset.changes.items %} +* {{ key|title}} + [old] {{ change.old }} + [new] {{ change.new }} +{% endfor %} +---{% endfor %} diff --git a/timed/tracking/templatetags/tracking_extras.py b/timed/tracking/templatetags/tracking_extras.py new file mode 100644 index 000000000..d1d2f945f --- /dev/null +++ b/timed/tracking/templatetags/tracking_extras.py @@ -0,0 +1,12 @@ +from django import template + +register = template.Library() + + +@register.filter +def duration(timedelta): + total_seconds = int(timedelta.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + + return f"{hours}:{minutes:02} (h:mm)" diff --git a/timed/tracking/tests/snapshots/__init__.py b/timed/tracking/tests/snapshots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/tracking/tests/snapshots/snap_test_report.py b/timed/tracking/tests/snapshots/snap_test_report.py new file mode 100644 index 000000000..754d1a4a1 --- /dev/null +++ b/timed/tracking/tests/snapshots/snap_test_report.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots[ + "test_report_notify_rendering 1" +] = """ +Some of your reports have been changed. + +Reviewer: Test User + + +Date: 11/13/1983 +Duration: 0:15 (h:mm) +Task: Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC +Comment: some other comment + +* Not_Billable + [old] True + [new] False + +--- + +Date: 07/10/1985 +Duration: 2:30 (h:mm) + +Comment: some other comment + +* Task + [old] Marsh, Gonzalez and Michael > Intuitive coherent hardware > and Sons + [new] Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC + +--- + +Date: 06/01/1999 +Duration: 3:15 (h:mm) + + + +* Task + [old] Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC + [new] Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC + +* Comment + [old] foo + [new] some other comment + +--- + +Date: 10/26/2002 +Duration: 1:00 (h:mm) +Task: Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC + + +* Comment + [old] original comment + [new] some other comment + +--- +""" diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index d9670d762..80f187b79 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -737,3 +737,162 @@ def test_report_export(auth_client, file_type, django_assert_num_queries): # bookdict is a dict of tuples(name, content) sheet = book.bookdict.popitem()[1] assert len(sheet) == len(reports) + 1 + + +def test_report_update_bulk_verify_reviewer_multiple_notify( + auth_client, task, task_factory, project, report_factory, user_factory, mailoutbox +): + reviewer = auth_client.user + project.reviewers.add(reviewer) + + user1, user2, user3 = user_factory.create_batch(3) + report1_1 = report_factory(user=user1, task=task) + report1_2 = report_factory(user=user1, task=task) + report2 = report_factory(user=user2, task=task) + report3 = report_factory(user=user3, task=task) + + other_task = task_factory() + + url = reverse("report-bulk") + + data = { + "data": { + "type": "report-bulks", + "id": None, + "attributes": {"verified": True, "comment": "some comment"}, + "relationships": {"task": {"data": {"type": "tasks", "id": other_task.id}}}, + } + } + + query_params = ( + "?editable=1" + f"&reviewer={reviewer.id}" + "&id=" + ",".join(str(r.id) for r in [report1_1, report1_2, report2, report3]) + ) + response = auth_client.post(url + query_params, data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + for report in [report1_1, report1_2, report2, report3]: + report.refresh_from_db() + assert report.verified_by == reviewer + assert report.comment == "some comment" + assert report.task == other_task + + # every user received one mail + assert len(mailoutbox) == 3 + assert all(True for mail in mailoutbox if len(mail.to) == 1) + assert set(mail.to[0] for mail in mailoutbox) == set( + user.email for user in [user1, user2, user3] + ) + + +@pytest.mark.parametrize("own_report", [True, False]) +@pytest.mark.parametrize( + "has_attributes,different_attributes,has_verified_by,expected", + [ + (True, True, True, True), + (True, True, False, True), + (True, False, True, False), + (False, None, True, False), + (False, None, False, False), + ], +) +def test_report_update_reviewer_notify( + auth_client, + user_factory, + report_factory, + task_factory, + mailoutbox, + own_report, + has_attributes, + different_attributes, + has_verified_by, + expected, +): + reviewer = auth_client.user + user = user_factory() + + if own_report: + report = report_factory(user=reviewer, review=True) + else: + report = report_factory(user=user, review=True) + report.task.project.reviewers.set([reviewer, user]) + new_task = task_factory(project=report.task.project) + + data = {"data": {"type": "reports", "id": report.id, "relationships": {}}} + if has_attributes: + if different_attributes: + data["data"]["attributes"] = {"comment": "foobar", "review": False} + data["data"]["relationships"]["task"] = { + "data": {"id": new_task.id, "type": "tasks"} + } + else: + data["data"]["attributes"] = {"comment": report.comment} + + if has_verified_by: + data["data"]["relationships"]["verified-by"] = { + "data": {"id": reviewer.id, "type": "users"} + } + + url = reverse("report-detail", args=[report.id]) + + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + mail_count = 1 if not own_report and expected else 0 + assert len(mailoutbox) == mail_count + + if mail_count: + mail = mailoutbox[0] + assert len(mail.to) == 1 + assert mail.to[0] == user.email + + +def test_report_notify_rendering( + auth_client, + user_factory, + project, + report_factory, + task_factory, + mailoutbox, + snapshot, +): + reviewer = auth_client.user + user = user_factory() + project.reviewers.add(reviewer) + task1, task2, task3 = task_factory.create_batch(3, project=project) + + report1 = report_factory( + user=user, task=task1, comment="original comment", not_billable=False + ) + report2 = report_factory( + user=user, task=task2, comment="some other comment", not_billable=False + ) + report3 = report_factory(user=user, task=task3, comment="foo", not_billable=False) + report4 = report_factory( + user=user, task=task1, comment=report2.comment, not_billable=True + ) + + data = { + "data": { + "type": "report-bulks", + "id": None, + "attributes": {"comment": report2.comment, "not-billable": False}, + "relationships": { + "task": {"data": {"id": report1.task.id, "type": "tasks"}} + }, + } + } + + url = reverse("report-bulk") + + query_params = ( + "?editable=1" + f"&reviewer={reviewer.id}" + "&id=" + ",".join(str(r.id) for r in [report1, report2, report3, report4]) + ) + response = auth_client.post(url + query_params, data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + assert len(mailoutbox) == 1 + snapshot.assert_match(mailoutbox[0].body) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 8342727a2..7437e9055 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -23,6 +23,8 @@ from timed.serializers import AggregateObject from timed.tracking import filters, models, serializers +from . import tasks + class ActivityViewSet(ModelViewSet): """Activity view set.""" @@ -66,6 +68,9 @@ def get_queryset(self): class ReportViewSet(ModelViewSet): """Report view set.""" + queryset = models.Report.objects.select_related( + "task", "user", "task__project", "task__project__customer" + ) serializer_class = serializers.ReportSerializer filterset_class = filters.ReportFilterSet permission_classes = [ @@ -120,6 +125,25 @@ def _extract_billing_type(self, report): return name + def update(self, request, *args, **kwargs): + """Override so we can issue emails on update.""" + + partial = kwargs.get("partial", False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + fields = { + key: value + for key, value in serializer.validated_data.items() + # value equal None means do not touch + if value is not None + } + if fields and request.user != instance.user: + tasks.notify_user_changed_report(instance, fields, request.user) + + return super().update(request, *args, **kwargs) + @action( detail=False, methods=["get"], @@ -162,12 +186,13 @@ def bulk(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + + verified = serializer.validated_data.pop("verified", None) fields = { key: value for key, value in serializer.validated_data.items() - # value equal None means do not touch whereas verified - # is handled separately - if value is not None and key != "verified" + # value equal None means do not touch + if value is not None } editable = request.query_params.get("editable") @@ -176,7 +201,6 @@ def bulk(self, request): _("Editable filter needs to be set for bulk update") ) - verified = serializer.validated_data.get("verified") if verified is not None: # only reviewer or superuser may verify reports # this is enforced when reviewer filter is set to current user @@ -186,10 +210,10 @@ def bulk(self, request): _("Reviewer filter needs to be set to verifying user") ) - verified_by = verified and user or None - fields["verified_by"] = verified_by + fields["verified_by"] = verified and user or None if fields: + tasks.notify_user_changed_reports(queryset, fields, user) queryset.update(**fields) return Response(status=status.HTTP_204_NO_CONTENT) @@ -238,16 +262,6 @@ def export(self, request): sheet, file_type=file_type, file_name="report.%s" % file_type ) - def get_queryset(self): - """Select related to reduce queries. - - :return: The filtered reports - :rtype: QuerySet - """ - return models.Report.objects.select_related("task", "user").select_related( - "task__project", "task__project__customer" - ) - class AbsenceViewSet(ModelViewSet): """Absence view set.""" From 2f9f1d3d8cfdfa01d2affb386d568db7b86c4f52 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 27 Feb 2020 11:34:54 +0100 Subject: [PATCH 648/980] feat(user): add is_reviewer flag --- timed/employment/models.py | 4 ++++ timed/employment/serializers.py | 2 ++ timed/employment/tests/test_user.py | 16 +++++++++++++++- timed/projects/tests/test_project.py | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 6e0161c2f..23cf584dc 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -352,6 +352,10 @@ class User(AbstractUser): objects = UserManager() + @property + def is_reviewer(self): + return self.reviews.exists() + @property def user_id(self): """Map to id to be able to use generic permissions.""" diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 75043b6e4..458f70c8a 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -41,6 +41,7 @@ class Meta: "supervisors", "tour_done", "username", + "is_reviewer", ] read_only_fields = [ "first_name", @@ -51,6 +52,7 @@ class Meta: "supervisees", "supervisors", "username", + "is_reviewer", ] diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 84f64934b..637d97b16 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -40,7 +40,7 @@ def test_user_list(auth_client, django_assert_num_queries): url = reverse("user-list") - with django_assert_num_queries(5): + with django_assert_num_queries(8): response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -215,3 +215,17 @@ def test_user_is_supervisor_filter(auth_client, value, expected): res = auth_client.get(reverse("user-list"), {"is_supervisor": value}) assert len(res.json()["data"]) == expected + + +def test_user_attributes(auth_client, project): + """Should filter users if they are a reviewer.""" + user = UserFactory.create() + + url = reverse("user-detail", args=[user.id]) + + res = auth_client.get(url) + assert not res.json()["data"]["attributes"]["is-reviewer"] + + project.reviewers.add(user) + res = auth_client.get(url) + assert res.json()["data"]["attributes"]["is-reviewer"] diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 0b18b0c85..a25788d17 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -31,7 +31,7 @@ def test_project_list_include(auth_client, django_assert_num_queries): url = reverse("project-list") - with django_assert_num_queries(6): + with django_assert_num_queries(8): response = auth_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, From d91972f049c8dd9ec0d931e3c2afc086a20d8b79 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2020 00:12:39 +0000 Subject: [PATCH 649/980] Bump ipdb from 0.12.3 to 0.13.2 Bumps [ipdb](https://github.com/gotcha/ipdb) from 0.12.3 to 0.13.2. - [Release notes](https://github.com/gotcha/ipdb/releases) - [Changelog](https://github.com/gotcha/ipdb/blob/master/HISTORY.txt) - [Commits](https://github.com/gotcha/ipdb/compare/0.12.3...0.13.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 1c6bcb71a..db333ed1e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,7 +9,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.5.0 flake8-isort==2.8.0 flake8-string-format==0.3.0 -ipdb==0.12.3 +ipdb==0.13.2 isort==4.3.21 mockldap==0.3.0.post1 pdbpp==0.10.2 From 207dbb974e73b14032f516f080525ab5590928cd Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Mon, 9 Mar 2020 18:07:50 +0100 Subject: [PATCH 650/980] feat: set Auto-Submitted header in emails This should force servers to not reply with absence notices and related stuff. Resolves #476 --- .../commands/notify_changed_employments.py | 8 ++++++-- .../commands/notify_reviewers_unverified.py | 1 + .../commands/notify_supervisors_shorttime.py | 15 ++++++++++++--- timed/settings.py | 1 + timed/tracking/tasks.py | 1 + 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/timed/reports/management/commands/notify_changed_employments.py b/timed/reports/management/commands/notify_changed_employments.py index 1712d7411..a56f6b9b2 100644 --- a/timed/reports/management/commands/notify_changed_employments.py +++ b/timed/reports/management/commands/notify_changed_employments.py @@ -48,8 +48,12 @@ def handle(self, *args, **options): if employments.exists(): from_email = settings.DEFAULT_FROM_EMAIL subject = "[Timed] Employments changed in last {0} days".format(last_days) - body = template.render({"employments": employments},) + body = template.render({"employments": employments}) message = EmailMessage( - subject=subject, body=body, from_email=from_email, to=[email] + subject=subject, + body=body, + from_email=from_email, + to=[email], + headers=settings.EMAIL_EXTRA_HEADERS, ) message.send() diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index b073b2d42..aaffaf73f 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -124,6 +124,7 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): to=[reviewer.email], cc=cc, connection=connection, + headers=settings.EMAIL_EXTRA_HEADERS, ) messages.append(message) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index bb5d6fea7..1ee6076c4 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -2,7 +2,7 @@ from django.conf import settings from django.contrib.auth import get_user_model -from django.core.mail import send_mass_mail +from django.core.mail import EmailMessage, get_connection from django.core.management.base import BaseCommand from django.template.loader import get_template @@ -132,7 +132,16 @@ def _notify_supervisors(self, start, end, ratio, supervisees): "suspects": suspects_shorttime, } ) - mails.append((subject, body, from_email, [supervisor.email])) + mails.append( + EmailMessage( + subject=subject, + body=body, + from_email=from_email, + to=[supervisor.email], + headers=settings.EMAIL_EXTRA_HEADERS, + ) + ) if len(mails) > 0: - send_mass_mail(mails) + connection = get_connection() + connection.send_messages(mails) diff --git a/timed/settings.py b/timed/settings.py index 83f30179b..63ee95d20 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -200,6 +200,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ) SERVER_EMAIL = env.str("DJANGO_SERVER_EMAIL", default("root@localhost")) +EMAIL_EXTRA_HEADERS = {"Auto-Submitted": "auto-generated"} def parse_admins(admins): diff --git a/timed/tracking/tasks.py b/timed/tracking/tasks.py index 401a6e058..66abe6338 100644 --- a/timed/tracking/tasks.py +++ b/timed/tracking/tasks.py @@ -31,6 +31,7 @@ def _send_notification_emails(changes, reviewer): from_email=from_email, to=[user.email], connection=connection, + headers=settings.EMAIL_EXTRA_HEADERS, ) messages.append(message) From 80c106bd2b6906f093c6050f2f13956997279c7a Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 6 Mar 2020 16:56:10 +0100 Subject: [PATCH 651/980] fix(tracking): can't verify report if it needs review --- .../0012_migrate_report_review_false.py | 17 ++++++ timed/tracking/serializers.py | 13 +++- timed/tracking/tests/test_report.py | 61 ++++++++++++++++--- timed/tracking/views.py | 9 +++ 4 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 timed/tracking/migrations/0012_migrate_report_review_false.py diff --git a/timed/tracking/migrations/0012_migrate_report_review_false.py b/timed/tracking/migrations/0012_migrate_report_review_false.py new file mode 100644 index 000000000..98f385699 --- /dev/null +++ b/timed/tracking/migrations/0012_migrate_report_review_false.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.10 on 2020-03-06 11:18 + +from django.db import migrations + + +def migrate_report_review(apps, schema_editor): + Report = apps.get_model("tracking", "Report") + Report.objects.filter(review=True).exclude(verified_by__isnull=True).update( + review=False + ) + + +class Migration(migrations.Migration): + + dependencies = [("tracking", "0011_auto_20181026_1528")] + + operations = [migrations.RunPython(migrate_report_review)] diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 95666a3d7..2615b6d51 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -118,11 +118,18 @@ def validate_duration(self, value): return self._validate_owner_only(value, "duration") def validate(self, data): - """Validate that verified by is only set by reviewer or superuser.""" + """ + Validate that verified by is only set by reviewer or superuser. + + Additionally make sure a report is cannot be verified_by if is still + needs review. + """ + user = self.context["request"].user current_verified_by = self.instance and self.instance.verified_by new_verified_by = data.get("verified_by") task = data.get("task") or self.instance.task + review = data.get("review") if new_verified_by != current_verified_by: is_reviewer = ( @@ -135,6 +142,10 @@ def validate(self, data): if new_verified_by is not None and new_verified_by != user: raise ValidationError(_("You may only verifiy with your own user")) + if new_verified_by and review: + raise ValidationError( + _("Report can't both be set as `review` and `verified`.") + ) return data class Meta: diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 80f187b79..4229886e9 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -551,6 +551,28 @@ def test_report_update_by_user(auth_client): assert response.status_code == status.HTTP_403_FORBIDDEN +def test_report_update_verified_and_review_reviewer(auth_client): + user = auth_client.user + report = ReportFactory.create(duration=timedelta(hours=2)) + report.task.project.reviewers.add(user) + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {"review": True}, + "relationships": { + "verified-by": {"data": {"id": user.pk, "type": "users"}} + }, + } + } + + url = reverse("report-detail", args=[report.id]) + + res = auth_client.patch(url, data) + assert res.status_code == status.HTTP_400_BAD_REQUEST + + def test_report_set_verified_by_user(auth_client): """Test that normal user may not verify report.""" user = auth_client.user @@ -788,7 +810,7 @@ def test_report_update_bulk_verify_reviewer_multiple_notify( @pytest.mark.parametrize("own_report", [True, False]) @pytest.mark.parametrize( - "has_attributes,different_attributes,has_verified_by,expected", + "has_attributes,different_attributes,verified,expected", [ (True, True, True, True), (True, True, False, True), @@ -806,7 +828,7 @@ def test_report_update_reviewer_notify( own_report, has_attributes, different_attributes, - has_verified_by, + verified, expected, ): reviewer = auth_client.user @@ -819,7 +841,14 @@ def test_report_update_reviewer_notify( report.task.project.reviewers.set([reviewer, user]) new_task = task_factory(project=report.task.project) - data = {"data": {"type": "reports", "id": report.id, "relationships": {}}} + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {}, + "relationships": {}, + } + } if has_attributes: if different_attributes: data["data"]["attributes"] = {"comment": "foobar", "review": False} @@ -829,10 +858,8 @@ def test_report_update_reviewer_notify( else: data["data"]["attributes"] = {"comment": report.comment} - if has_verified_by: - data["data"]["relationships"]["verified-by"] = { - "data": {"id": reviewer.id, "type": "users"} - } + if verified: + data["data"]["attributes"]["verified"] = verified url = reverse("report-detail", args=[report.id]) @@ -896,3 +923,23 @@ def test_report_notify_rendering( assert len(mailoutbox) == 1 snapshot.assert_match(mailoutbox[0].body) + + +@pytest.mark.parametrize( + "report__review,needs_review", [(True, False), (False, True), (True, True)] +) +def test_report_update_bulk_review_and_verified( + superadmin_client, project, task, report, user_factory, needs_review +): + data = { + "data": {"type": "report-bulks", "id": None, "attributes": {"verified": True}} + } + + if needs_review: + data["data"]["attributes"]["review"] = True + + url = reverse("report-bulk") + + query_params = f"?id={report.id}" + response = superadmin_client.post(url + query_params, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 7437e9055..e8662eee5 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -212,6 +212,15 @@ def bulk(self, request): fields["verified_by"] = verified and user or None + if ( + "review" in fields + and fields["review"] + or any(queryset.values_list("review", flat=True)) + ): + raise exceptions.ParseError( + _("Reports can't both be set as `review` and `verified`.") + ) + if fields: tasks.notify_user_changed_reports(queryset, fields, user) queryset.update(**fields) From 57be7c9f52e08efe007178d43200667175bd3b33 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 6 Mar 2020 15:51:34 +0100 Subject: [PATCH 652/980] fix: partially revert template settings The previous clean-up commit removed the default TEMPLATE setting. Turns out that this actually needed for the django-admin. Partially reverts c8b41c9e5560183dbc795498d670c33c5f1200a2 --- .../management/commands/redmine_report.py | 2 +- .../commands/notify_changed_employments.py | 2 +- .../commands/notify_reviewers_unverified.py | 2 +- .../commands/notify_supervisors_shorttime.py | 2 +- timed/settings.py | 17 +++++++++++++++-- timed/tracking/tasks.py | 2 +- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 7f7858a95..8fc59e142 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -11,7 +11,7 @@ from timed.projects.models import Project from timed.tracking.models import Report -template = get_template("redmine/weekly_report.txt") +template = get_template("redmine/weekly_report.txt", using="text") class Command(BaseCommand): diff --git a/timed/reports/management/commands/notify_changed_employments.py b/timed/reports/management/commands/notify_changed_employments.py index a56f6b9b2..6fe8cb08f 100644 --- a/timed/reports/management/commands/notify_changed_employments.py +++ b/timed/reports/management/commands/notify_changed_employments.py @@ -8,7 +8,7 @@ from timed.employment.models import Employment -template = get_template("mail/notify_changed_employments.txt") +template = get_template("mail/notify_changed_employments.txt", using="text") class Command(BaseCommand): diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index aaffaf73f..60516afd4 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -10,7 +10,7 @@ from timed.tracking.models import Report -template = get_template("mail/notify_reviewers_unverified.txt") +template = get_template("mail/notify_reviewers_unverified.txt", using="text") class Command(BaseCommand): diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/reports/management/commands/notify_supervisors_shorttime.py index 1ee6076c4..a7da26264 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/reports/management/commands/notify_supervisors_shorttime.py @@ -6,7 +6,7 @@ from django.core.management.base import BaseCommand from django.template.loader import get_template -template = get_template("mail/notify_supervisor_shorttime.txt") +template = get_template("mail/notify_supervisor_shorttime.txt", using="text") class Command(BaseCommand): diff --git a/timed/settings.py b/timed/settings.py index 63ee95d20..286178d1b 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -82,10 +82,23 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): FORM_RENDERER = "django.forms.renderers.TemplatesSetting" TEMPLATES = [ + # default: needed for django-admin + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "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", + ] + }, + }, # template backend for plain text (no escaping) { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [django_root("timed", "templates")], + "NAME": "text", "APP_DIRS": True, "OPTIONS": { "autoescape": False, @@ -96,7 +109,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django.contrib.messages.context_processors.messages", ], }, - } + }, ] WSGI_APPLICATION = "timed.wsgi.application" diff --git a/timed/tracking/tasks.py b/timed/tracking/tasks.py index 66abe6338..8b320b6ba 100644 --- a/timed/tracking/tasks.py +++ b/timed/tracking/tasks.py @@ -2,7 +2,7 @@ from django.core.mail import EmailMessage, get_connection from django.template.loader import get_template -template = get_template("mail/notify_user_changed_reports.tmpl") +template = get_template("mail/notify_user_changed_reports.tmpl", using="text") def _send_notification_emails(changes, reviewer): From 5039f9d9a9403adbf608c6bc3ff94b0579ca4f76 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 6 Mar 2020 16:48:11 +0100 Subject: [PATCH 653/980] fix(admin): validate method for DurationInHoursField Override superclass method `validate` for DurationInHoursField. During the Django upgrade, more strict validation was introduced for FloatField, our parent class, forcing it to be a float. Thus we override the method, casting back to hours instead of datetime.timedelta. --- timed/forms.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/timed/forms.py b/timed/forms.py index db168726b..3fd211be1 100644 --- a/timed/forms.py +++ b/timed/forms.py @@ -1,14 +1,19 @@ from datetime import timedelta -from django import forms +from django.core.exceptions import ValidationError +from django.forms.fields import FloatField +from django.utils.translation import ugettext_lazy as _ -class DurationInHoursField(forms.fields.FloatField): +class DurationInHoursField(FloatField): """Field representing duration as float hours.""" + def _get_hours(self, value): + return value.total_seconds() / 3600 + def prepare_value(self, value): if isinstance(value, timedelta): - return value.total_seconds() / 3600 + return self._get_hours(value) return value def to_python(self, value): @@ -17,3 +22,12 @@ def to_python(self, value): return value return timedelta(seconds=value * 3600) + + def validate(self, value): + if value in self.empty_values: + return + + if not isinstance(value, timedelta): + raise ValidationError(_("Enter a datetime.timedelta")) + + super().validate(self._get_hours(value)) From 1f90b576e620dd5402d29c874de4b5e5f73cf06e Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 11 Mar 2020 11:36:05 +0100 Subject: [PATCH 654/980] feat: show spent_billable time on projects --- timed/projects/serializers.py | 7 +++++ timed/projects/tests/test_project.py | 39 ++++++++++++++-------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 07222dccd..d109b9e77 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -50,6 +50,13 @@ def get_root_meta(self, resource, many): queryset = Report.objects.filter(task__project=self.instance) data = queryset.aggregate(spent_time=Sum("duration")) data["spent_time"] = duration_string(data["spent_time"] or timedelta(0)) + + billable_data = queryset.filter(not_billable=False, review=False).aggregate( + spent_billable=Sum("duration") + ) + data["spent_billable"] = duration_string( + billable_data["spent_billable"] or timedelta(0) + ) return data return {} diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index a25788d17..bf50c98a6 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -5,9 +5,8 @@ from rest_framework import status from timed.employment.factories import UserFactory -from timed.projects.factories import ProjectFactory, TaskFactory +from timed.projects.factories import ProjectFactory from timed.projects.serializers import ProjectSerializer -from timed.tracking.factories import ReportFactory def test_project_list_not_archived(auth_client): @@ -24,8 +23,7 @@ def test_project_list_not_archived(auth_client): assert json["data"][0]["id"] == str(project.id) -def test_project_list_include(auth_client, django_assert_num_queries): - project = ProjectFactory.create() +def test_project_list_include(auth_client, django_assert_num_queries, project): users = UserFactory.create_batch(2) project.reviewers.add(*users) @@ -43,18 +41,14 @@ def test_project_list_include(auth_client, django_assert_num_queries): assert json["data"][0]["id"] == str(project.id) -def test_project_detail_no_auth(client): - project = ProjectFactory.create() - +def test_project_detail_no_auth(client, project): url = reverse("project-detail", args=[project.id]) res = client.get(url) assert res.status_code == status.HTTP_401_UNAUTHORIZED -def test_project_detail_no_reports(auth_client): - project = ProjectFactory.create() - +def test_project_detail_no_reports(auth_client, project): url = reverse("project-detail", args=[project.id]) res = auth_client.get(url) @@ -63,12 +57,20 @@ def test_project_detail_no_reports(auth_client): json = res.json() assert json["meta"]["spent-time"] == "00:00:00" + assert json["meta"]["spent-billable"] == "00:00:00" -def test_project_detail_with_reports(auth_client): - project = ProjectFactory.create() - task = TaskFactory.create(project=project) - ReportFactory.create_batch(10, task=task, duration=timedelta(hours=1)) +def test_project_detail_with_reports(auth_client, project, task, report_factory): + rep1, rep2, rep3, *_ = report_factory.create_batch( + 10, task=task, duration=timedelta(hours=1) + ) + rep1.not_billable = True + rep1.save() + rep2.review = True + rep2.save() + rep3.not_billable = True + rep3.review = True + rep3.save() url = reverse("project-detail", args=[project.id]) @@ -78,6 +80,7 @@ def test_project_detail_with_reports(auth_client): json = res.json() assert json["meta"]["spent-time"] == "10:00:00" + assert json["meta"]["spent-billable"] == "07:00:00" def test_project_create(auth_client): @@ -87,18 +90,14 @@ def test_project_create(auth_client): assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED -def test_project_update(auth_client): - project = ProjectFactory.create() - +def test_project_update(auth_client, project): url = reverse("project-detail", args=[project.id]) response = auth_client.patch(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED -def test_project_delete(auth_client): - project = ProjectFactory.create() - +def test_project_delete(auth_client, project): url = reverse("project-detail", args=[project.id]) response = auth_client.delete(url) From 4574932985d3745922dd13a40fc77841cad90a2b Mon Sep 17 00:00:00 2001 From: Strix <660956+MrStrix@users.noreply.github.com> Date: Fri, 13 Mar 2020 16:36:48 +0100 Subject: [PATCH 655/980] feat: added timeout var for bash script --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fd152afc3..cbe2a87a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ ARG REQUIREMENTS=requirements.txt ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static ENV UWSGI_INI /app/uwsgi.ini +ENV WAITFORIT_TIMEOUT 0 COPY requirements.txt requirements-dev.txt /app/ RUN pip install --upgrade --no-cache-dir --requirement $REQUIREMENTS --disable-pip-version-check @@ -26,4 +27,4 @@ RUN mkdir -p /var/www/static \ && ENV=docker ./manage.py collectstatic --noinput EXPOSE 80 -CMD /bin/sh -c "wait-for-it.sh $DJANGO_DATABASE_HOST:$DJANGO_DATABASE_PORT -- ./manage.py migrate && uwsgi" +CMD /bin/sh -c "wait-for-it.sh $DJANGO_DATABASE_HOST:$DJANGO_DATABASE_PORT -t $WAITFORIT_TIMEOUT -- ./manage.py migrate && uwsgi" From 2284bf764f239944027c323e4ad4a0fc97de7383 Mon Sep 17 00:00:00 2001 From: Yelinz Date: Thu, 12 Mar 2020 09:24:34 +0100 Subject: [PATCH 656/980] chore: add docker compose add mailhog and frontend containers --- ...ose.override.yml => docker-compose.dev.yml | 6 ++++- docker-compose.yml | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) rename docker-compose.override.yml => docker-compose.dev.yml (63%) diff --git a/docker-compose.override.yml b/docker-compose.dev.yml similarity index 63% rename from docker-compose.override.yml rename to docker-compose.dev.yml index 0ef8cb56a..31052af15 100644 --- a/docker-compose.override.yml +++ b/docker-compose.dev.yml @@ -1,12 +1,16 @@ version: "3" + services: backend: build: context: . args: REQUIREMENTS: requirements-dev.txt + depends_on: + - mailhog environment: - PYTHONDONTWRITEBYTECODE=1 + - EMAIL_URL=smtp://localhost:1025 volumes: - ./:/app - command: /bin/sh -c "wait-for-it.sh db:3306 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" + command: /bin/sh -c "wait-for-it.sh db:5432 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" diff --git a/docker-compose.yml b/docker-compose.yml index bbbe8db25..a117c0308 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,38 @@ version: "3" -volumes: - dbdata: - services: db: image: postgres:9.4 ports: - - "5432:5432" + - 5432:5432 volumes: - dbdata:/var/lib/postgresql/data environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed + + frontend: + image: adfinissygroup/timed-frontend:latest + ports: + - 4200:80 + backend: build: . ports: - - "8000:80" + - 8000:80 depends_on: - db environment: - DJANGO_DATABASE_HOST=db - ENV=docker - STATIC_ROOT=/var/www/static + + mailhog: + image: mailhog/mailhog + ports: + - 8025:8025 + environment: + - MH_UI_WEB_PATH=mailhog + +volumes: + dbdata: From 8020ebd2afec53fdf0941472509827d2d189189f Mon Sep 17 00:00:00 2001 From: Yelinz Date: Fri, 13 Mar 2020 09:25:55 +0100 Subject: [PATCH 657/980] chore: add test data --- Dockerfile | 8 +- Makefile | 6 +- README.md | 2 + docker-compose.dev.yml | 16 -- docker-compose.override.yml | 23 +++ docker-compose.yml | 12 +- timed/fixtures/test_data.json | 282 ++++++++++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 29 deletions(-) delete mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.override.yml create mode 100644 timed/fixtures/test_data.json diff --git a/Dockerfile b/Dockerfile index cbe2a87a8..640a92d48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,13 @@ FROM python:3.8 WORKDIR /app RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -P /usr/local/bin \ -&& chmod +x /usr/local/bin/wait-for-it.sh + && chmod +x /usr/local/bin/wait-for-it.sh RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ -&& rm -rf /var/lib/apt/lists/* \ -&& mkdir -p /app + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app ARG REQUIREMENTS=requirements.txt @@ -24,7 +24,7 @@ RUN pip install --upgrade --no-cache-dir --requirement $REQUIREMENTS --disable-p COPY . /app RUN mkdir -p /var/www/static \ -&& ENV=docker ./manage.py collectstatic --noinput + && ENV=docker ./manage.py collectstatic --noinput EXPOSE 80 CMD /bin/sh -c "wait-for-it.sh $DJANGO_DATABASE_HOST:$DJANGO_DATABASE_PORT -t $WAITFORIT_TIMEOUT -- ./manage.py migrate && uwsgi" diff --git a/Makefile b/Makefile index 45f07e45e..2162259cb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help start test shell +.PHONY: help start test shell loaddata .DEFAULT_GOAL := help help: @@ -6,9 +6,13 @@ help: start: ## Start the development server @docker-compose up -d --build + @make loaddata test: ## Test the project @docker-compose exec backend sh -c "black --check . && flake8 && pytest --no-cov-on-fail --cov --create-db" shell: ## Shell into the backend @docker-compose exec backend bash + +loaddata: ## Loads test data into the database + @docker-compose exec backend ./manage.py loaddata timed/fixtures/test_data.json diff --git a/README.md b/README.md index 0dbf9b7b4..584afc314 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ You can now access the API at http://localhost:8000/api/v1 and the admin interfa For end user interface have a look at our [Timed Frontend](https://github.com/adfinis-sygroup/timed-frontend) project. +The end user interface is included and is running under http://localhost:4200 + ## Development To get the application working locally for development, make sure to create a file `.env` with the following content: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 31052af15..000000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: "3" - -services: - backend: - build: - context: . - args: - REQUIREMENTS: requirements-dev.txt - depends_on: - - mailhog - environment: - - PYTHONDONTWRITEBYTECODE=1 - - EMAIL_URL=smtp://localhost:1025 - volumes: - - ./:/app - command: /bin/sh -c "wait-for-it.sh db:5432 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 000000000..aa35375e7 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,23 @@ +version: "3" + +services: + backend: + build: + context: . + args: + REQUIREMENTS: requirements-dev.txt + depends_on: + - mailhog + environment: + - PYTHONDONTWRITEBYTECODE=1 + - EMAIL_URL=smtp://mailhog:1025 + volumes: + - ./:/app + command: /bin/sh -c "wait-for-it.sh -t 60 db:5432 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" + + mailhog: + image: mailhog/mailhog + ports: + - 8025:8025 + environment: + - MH_UI_WEB_PATH=mailhog diff --git a/docker-compose.yml b/docker-compose.yml index a117c0308..688f1fc93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: db: - image: postgres:9.4 + image: postgres:9.6 ports: - 5432:5432 volumes: @@ -13,6 +13,8 @@ services: frontend: image: adfinissygroup/timed-frontend:latest + depends_on: + - backend ports: - 4200:80 @@ -24,15 +26,9 @@ services: - db environment: - DJANGO_DATABASE_HOST=db + - DJANGO_DATABASE_PORT=5432 - ENV=docker - STATIC_ROOT=/var/www/static - mailhog: - image: mailhog/mailhog - ports: - - 8025:8025 - environment: - - MH_UI_WEB_PATH=mailhog - volumes: dbdata: diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json new file mode 100644 index 000000000..c78b8d76f --- /dev/null +++ b/timed/fixtures/test_data.json @@ -0,0 +1,282 @@ +[ + { + "model": "employment.location", + "pk": 1, + "fields": { "name": "Location 1", "workdays": "1,2,3,4,5" } + }, + { + "model": "employment.absencetype", + "pk": 1, + "fields": { "name": "Holidays", "fill_worktime": true } + }, + { + "model": "employment.absencetype", + "pk": 2, + "fields": { "name": "Sickness", "fill_worktime": false } + }, + { + "model": "projects.customer", + "pk": 1, + "fields": { + "name": "Customer", + "reference": null, + "email": "customer@customer.customer", + "website": "", + "comment": "", + "archived": false + } + }, + { + "model": "projects.customer", + "pk": 2, + "fields": { + "name": "Bob", + "reference": null, + "email": "", + "website": "", + "comment": "", + "archived": false + } + }, + { + "model": "projects.costcenter", + "pk": 1, + "fields": { "name": "Cash", "reference": null } + }, + { + "model": "projects.billingtype", + "pk": 1, + "fields": { "name": "Cash", "reference": null } + }, + { + "model": "projects.task", + "pk": 1, + "fields": { + "name": "Work", + "reference": null, + "estimated_time": "2 02:00:00", + "archived": false, + "project": 1, + "cost_center": 1 + } + }, + { + "model": "projects.task", + "pk": 2, + "fields": { + "name": "Special Work", + "reference": null, + "estimated_time": "4 04:00:00", + "archived": false, + "project": 1, + "cost_center": null + } + }, + { + "model": "projects.task", + "pk": 3, + "fields": { + "name": "No Work", + "reference": null, + "estimated_time": null, + "archived": false, + "project": 1, + "cost_center": null + } + }, + { + "model": "projects.task", + "pk": 4, + "fields": { + "name": "Some Work", + "reference": null, + "estimated_time": "05:00:00", + "archived": false, + "project": 2, + "cost_center": 1 + } + }, + { + "model": "projects.task", + "pk": 5, + "fields": { + "name": "Build", + "reference": null, + "estimated_time": null, + "archived": false, + "project": 3, + "cost_center": null + } + }, + { + "model": "employment.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$150000$Ea7gV4CajWpZ$CWkosh5fs+Q13a6h7X976Zp9Cg1s5eaWInWVMG9sY7M=", + "last_login": "2020-03-12T09:24:33.471Z", + "is_superuser": true, + "username": "admin", + "first_name": "", + "email": "admin@admin.admin", + "is_staff": true, + "is_active": true, + "date_joined": "2020-03-12T09:24:27.469Z", + "tour_done": false, + "last_name": "", + "groups": [], + "user_permissions": [], + "supervisors": [] + } + }, + { + "model": "employment.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$150000$pK2Jl6xl4iYO$NvhTs+T85I5Z9RTzTm/QNXbk20iM384gst9Nj0nWWrI=", + "last_login": null, + "is_superuser": false, + "username": "user", + "first_name": "John", + "email": "john@john.john", + "is_staff": false, + "is_active": true, + "date_joined": "2020-03-12T09:27:21Z", + "tour_done": true, + "last_name": "Doe", + "groups": [], + "user_permissions": [], + "supervisors": [3] + } + }, + { + "model": "employment.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$150000$R6spIXkVyNm7$Qg2vsL0klTpgTqRwXm9bu0efHtYM8aAVYsgcXqVJsF0=", + "last_login": null, + "is_superuser": false, + "username": "supervisor", + "first_name": "Johnson", + "email": "johnson@johnson.johnson", + "is_staff": false, + "is_active": true, + "date_joined": "2020-03-12T09:28:55Z", + "tour_done": true, + "last_name": "Doeson", + "groups": [], + "user_permissions": [], + "supervisors": [] + } + }, + { + "model": "projects.project", + "pk": 1, + "fields": { + "name": "Big Project", + "reference": null, + "comment": "A very big project", + "archived": false, + "estimated_time": "20 20:00:00", + "customer": 1, + "billing_type": 1, + "cost_center": 1, + "customer_visible": false, + "reviewers": [3] + } + }, + { + "model": "projects.project", + "pk": 2, + "fields": { + "name": "Small Project", + "reference": null, + "comment": "A small project", + "archived": false, + "estimated_time": "1 06:00:00", + "customer": 1, + "billing_type": 1, + "cost_center": 1, + "customer_visible": false, + "reviewers": [] + } + }, + { + "model": "projects.project", + "pk": 3, + "fields": { + "name": "Building", + "reference": null, + "comment": "", + "archived": false, + "estimated_time": "4 04:00:00", + "customer": 2, + "billing_type": null, + "cost_center": null, + "customer_visible": false, + "reviewers": [] + } + }, + { + "model": "employment.employment", + "pk": 1, + "fields": { + "user": 2, + "location": 1, + "percentage": 100, + "worktime_per_day": "08:00:00", + "start_date": "2020-01-01", + "end_date": null, + "added": "2020-03-12T09:27:21.761Z", + "updated": "2020-03-12T09:27:21.761Z" + } + }, + { + "model": "employment.employment", + "pk": 2, + "fields": { + "user": 3, + "location": 1, + "percentage": 80, + "worktime_per_day": "06:00:00", + "start_date": "2020-01-01", + "end_date": null, + "added": "2020-03-12T09:28:55.640Z", + "updated": "2020-03-12T09:28:55.640Z" + } + }, + { + "model": "employment.overtimecredit", + "pk": 1, + "fields": { + "user": 2, + "comment": "Overtime", + "date": "2020-01-01", + "duration": "20:00:00", + "transfer": false + } + }, + { + "model": "employment.absencecredit", + "pk": 1, + "fields": { + "user": 2, + "comment": "Absence", + "absence_type": 1, + "date": "2020-01-01", + "days": 20, + "transfer": false + } + }, + { + "model": "employment.absencecredit", + "pk": 2, + "fields": { + "user": 2, + "comment": "Sickness", + "absence_type": 2, + "date": "2020-01-01", + "days": 5, + "transfer": false + } + } +] From 581d1774c51cbcb8a49f71966a42d9db05a30c9e Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Thu, 19 Mar 2020 10:47:30 +0100 Subject: [PATCH 658/980] Add employment for superuser in test data --- timed/fixtures/test_data.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json index c78b8d76f..9e55e149e 100644 --- a/timed/fixtures/test_data.json +++ b/timed/fixtures/test_data.json @@ -244,6 +244,20 @@ "updated": "2020-03-12T09:28:55.640Z" } }, + { + "model": "employment.employment", + "pk": 3, + "fields": { + "user": 1, + "location": 1, + "percentage": 100, + "worktime_per_day": "08:00:00", + "start_date": "2020-01-01", + "end_date": null, + "added": "2020-03-19T09:27:21.761Z", + "updated": "2020-03-19T09:27:21.761Z" + } + }, { "model": "employment.overtimecredit", "pk": 1, From 234e2a6559e105cd5f541ce65dfe895398234f38 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Tue, 24 Mar 2020 12:02:31 +0100 Subject: [PATCH 659/980] Update absence types in test data --- timed/fixtures/test_data.json | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json index 9e55e149e..1c4a7ebcf 100644 --- a/timed/fixtures/test_data.json +++ b/timed/fixtures/test_data.json @@ -7,12 +7,17 @@ { "model": "employment.absencetype", "pk": 1, - "fields": { "name": "Holidays", "fill_worktime": true } + "fields": { "name": "EO", "fill_worktime": false } }, { "model": "employment.absencetype", "pk": 2, - "fields": { "name": "Sickness", "fill_worktime": false } + "fields": { "name": "Ferien", "fill_worktime": false } + }, + { + "model": "employment.absencetype", + "pk": 3, + "fields": { "name": "Krankheit", "fill_worktime": true } }, { "model": "projects.customer", @@ -116,13 +121,13 @@ "last_login": "2020-03-12T09:24:33.471Z", "is_superuser": true, "username": "admin", - "first_name": "", + "first_name": "Admin", "email": "admin@admin.admin", "is_staff": true, "is_active": true, "date_joined": "2020-03-12T09:24:27.469Z", "tour_done": false, - "last_name": "", + "last_name": "Strator", "groups": [], "user_permissions": [], "supervisors": [] From 2d97f6984130909453267b886cd8535405bcf467 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 24 Mar 2020 14:27:42 +0100 Subject: [PATCH 660/980] fix: replace deprecated pagination middleware --- pytest.ini | 7 ------- timed/settings.py | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/pytest.ini b/pytest.ini index daa784b07..bebf1ee3d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -8,10 +8,3 @@ env = filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - - # ignore rest_framework_jwt deprecation warning which is not fixiable - # but simply a information - ignore:The following fields will be removed in the future:DeprecationWarning - - # TODO: adjust frontend for this change to work - ignore:PageNumberPagination is deprecated. Use JsonApiPageNumberPagination instead. diff --git a/timed/settings.py b/timed/settings.py index 286178d1b..45e769839 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -156,7 +156,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ), "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", - "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.PageNumberPagination", + "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", "DEFAULT_RENDERER_CLASSES": ("rest_framework_json_api.renderers.JSONRenderer",), } From ed29d71867f937eebafcdb4d51d7eedc4b92bb3b Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 24 Mar 2020 13:21:11 +0100 Subject: [PATCH 661/980] feat(tracking): configurable fields for changes mail Make fields showing up in changed reports mail configurable Default: ["task", "comment", "not_billable"] --- timed/settings.py | 7 +++++++ timed/tracking/tasks.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index 286178d1b..c5e031c89 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -253,3 +253,10 @@ def parse_admins(admins): "DJANGO_WORK_REPORT_PATH", default=resource_filename("timed.reports", "templates/workreport.ots"), ) + +# Tracking: fields which should be included in email (when report was changed +# during verification) +TRACKING_REPORT_VERIFIED_CHANGES = env.list( + "DJANGO_TRACKING_REPORT_VERIFIED_CHANGES", + default=default(["task", "comment", "not_billable"]), +) diff --git a/timed/tracking/tasks.py b/timed/tracking/tasks.py index 8b320b6ba..1f29766c4 100644 --- a/timed/tracking/tasks.py +++ b/timed/tracking/tasks.py @@ -47,7 +47,7 @@ def _get_report_changeset(report, fields): for key in fields.keys() # skip if field is not changed or just a reviewer field if getattr(report, key) != fields[key] - and key not in ["review", "verified_by"] + and key in settings.TRACKING_REPORT_VERIFIED_CHANGES }, } if not changeset["changes"]: From 5c4da79fb9abfa32e02b6a608d7723200a1fae8d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2020 12:48:49 +0000 Subject: [PATCH 662/980] Bump coverage from 5.0.3 to 5.0.4 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.0.3 to 5.0.4. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.0.3...coverage-5.0.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index db333ed1e..1c9024a79 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt black==19.10b0 -coverage==5.0.3 +coverage==5.0.4 factory-boy==2.12.0 flake8==3.7.9 flake8-blind-except==0.1.1 From 57bf7762054f375e4aa0b4f3ac4a7f3fb64e1c09 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2020 12:49:09 +0000 Subject: [PATCH 663/980] Bump flake8-isort from 2.8.0 to 2.9.0 Bumps [flake8-isort](https://github.com/gforcada/flake8-isort) from 2.8.0 to 2.9.0. - [Release notes](https://github.com/gforcada/flake8-isort/releases) - [Changelog](https://github.com/gforcada/flake8-isort/blob/master/CHANGES.rst) - [Commits](https://github.com/gforcada/flake8-isort/compare/2.8.0...2.9.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index db333ed1e..30760a211 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.2.1 flake8-deprecated==1.3 flake8-docstrings==1.5.0 -flake8-isort==2.8.0 +flake8-isort==2.9.0 flake8-string-format==0.3.0 ipdb==0.13.2 isort==4.3.21 From 2b1b961b7f8c90706e04b37a7f38934e2b9f0aed Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2020 12:49:21 +0000 Subject: [PATCH 664/980] Bump pytest from 5.3.5 to 5.4.1 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.3.5 to 5.4.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.3.5...5.4.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index db333ed1e..531db3e92 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.13.2 isort==4.3.21 mockldap==0.3.0.post1 pdbpp==0.10.2 -pytest==5.3.5 +pytest==5.4.1 pytest-cov==2.8.1 pytest-django==3.8.0 pytest-env==0.6.2 From a5e1f8dd64abcde9013ed2b4038539329ee093b8 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Thu, 26 Mar 2020 15:27:31 +0100 Subject: [PATCH 665/980] fix(testdata): Update superuser password The password now meets the Adsy policy --- timed/fixtures/test_data.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json index 1c4a7ebcf..4a7b25905 100644 --- a/timed/fixtures/test_data.json +++ b/timed/fixtures/test_data.json @@ -117,7 +117,7 @@ "model": "employment.user", "pk": 1, "fields": { - "password": "pbkdf2_sha256$150000$Ea7gV4CajWpZ$CWkosh5fs+Q13a6h7X976Zp9Cg1s5eaWInWVMG9sY7M=", + "password": "pbkdf2_sha256$150000$lgPMtNRsmi6E$l+HWaKslNGUOpRniI2AqeMxJpn8nDBCIfK4cMO/WcRo=", "last_login": "2020-03-12T09:24:33.471Z", "is_superuser": true, "username": "admin", From d8f794272e322772dbe73feee568cbb893c43f87 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2020 00:14:13 +0000 Subject: [PATCH 666/980] Bump pytest-mock from 2.0.0 to 3.0.0 Bumps [pytest-mock](https://github.com/pytest-dev/pytest-mock) from 2.0.0 to 3.0.0. - [Release notes](https://github.com/pytest-dev/pytest-mock/releases) - [Changelog](https://github.com/pytest-dev/pytest-mock/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-mock/compare/v2.0.0...v3.0.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e9b1e937f..b21283c5b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,6 +19,6 @@ pytest-django==3.8.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 -pytest-mock==2.0.0 +pytest-mock==3.0.0 pytest-randomly==3.2.1 snapshottest==0.5.1 From 923f8e23f2e1e30fd43d8ee09c6fbb0d18028013 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Tue, 7 Apr 2020 16:25:39 +0200 Subject: [PATCH 667/980] Match postgres version with production --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 688f1fc93..ec2bd9f93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: db: - image: postgres:9.6 + image: postgres:9.4 ports: - 5432:5432 volumes: From c72058d76404d1bad2454fb676e6fe3eea778f19 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Tue, 7 Apr 2020 20:53:13 +0200 Subject: [PATCH 668/980] fix(db): generate missing migration --- .../migrations/0004_auto_20200407_2052.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 timed/subscription/migrations/0004_auto_20200407_2052.py diff --git a/timed/subscription/migrations/0004_auto_20200407_2052.py b/timed/subscription/migrations/0004_auto_20200407_2052.py new file mode 100644 index 000000000..eb4f63cac --- /dev/null +++ b/timed/subscription/migrations/0004_auto_20200407_2052.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.10 on 2020-04-07 18:52 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [("subscription", "0003_auto_20170907_1151")] + + operations = [ + migrations.AlterField( + model_name="package", + name="price", + field=djmoney.models.fields.MoneyField( + decimal_places=2, default_currency="CHF", max_digits=7 + ), + ) + ] From 4ad6201315777aace58edc2d0959f993d71c20a6 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 8 Apr 2020 11:23:00 +0200 Subject: [PATCH 669/980] feat(ci): check if migrations are missing --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 65619f3c9..8b3aab46f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,6 +22,7 @@ before_script: script: - black --check . - flake8 + - ./manage.py makemigrations --check --dry-run --no-input - pytest --no-cov-on-fail --cov --create-db after_success: From 7d3be213b23e308df3e61f94cf11d54aeb2d840f Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 9 Apr 2020 13:33:25 +0200 Subject: [PATCH 670/980] chore: move to psycopg2 wheel The psycopg2-bin package is intended for getting a dev setup running quickly, but not for production use. See: https://www.psycopg.org/docs/install.html#binary-install-from-pypi --- Dockerfile | 1 + requirements.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 640a92d48..f9f1285d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for RUN apt-get update && apt-get install -y --no-install-recommends \ libldap2-dev \ libsasl2-dev \ + libpq-dev \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /app diff --git a/requirements.txt b/requirements.txt index c86e215e3..32c20ad3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-multiselectfield==0.1.12 djangorestframework==3.11.0 djangorestframework-simplejwt==4.4.0 djangorestframework-jsonapi[django-filter]==3.1.0 -psycopg2-binary==2.8.4 +psycopg2==2.8.4 pytz==2019.3 pyexcel-webio==0.1.4 pyexcel-io==0.5.20 From 255d0365a0bf5c8af016b11329de1b380337db74 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2020 00:12:36 +0000 Subject: [PATCH 671/980] chore(deps): bump psycopg2 from 2.8.4 to 2.8.5 Bumps [psycopg2](https://github.com/psycopg/psycopg2) from 2.8.4 to 2.8.5. - [Release notes](https://github.com/psycopg/psycopg2/releases) - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/commits) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 32c20ad3c..38e43c467 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-multiselectfield==0.1.12 djangorestframework==3.11.0 djangorestframework-simplejwt==4.4.0 djangorestframework-jsonapi[django-filter]==3.1.0 -psycopg2==2.8.4 +psycopg2==2.8.5 pytz==2019.3 pyexcel-webio==0.1.4 pyexcel-io==0.5.20 From 36cc5cb108f349c090b9856160338b2bb86edd73 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2020 00:15:53 +0000 Subject: [PATCH 672/980] chore(deps-dev): bump coverage from 5.0.4 to 5.1 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.0.4 to 5.1. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.0.4...coverage-5.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e9b1e937f..b0ae2d6ec 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt black==19.10b0 -coverage==5.0.4 +coverage==5.1 factory-boy==2.12.0 flake8==3.7.9 flake8-blind-except==0.1.1 From ffc7b3b38fcd8d5d1852bb5a17075a0ce5165487 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 14 Apr 2020 09:26:53 +0200 Subject: [PATCH 673/980] chore(deps): update various dependencies --- requirements-dev.txt | 4 ++-- requirements.txt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e9b1e937f..c99c9b9d1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.2.1 flake8-deprecated==1.3 flake8-docstrings==1.5.0 -flake8-isort==2.9.0 +flake8-isort==2.9.1 flake8-string-format==0.3.0 ipdb==0.13.2 isort==4.3.21 @@ -15,7 +15,7 @@ mockldap==0.3.0.post1 pdbpp==0.10.2 pytest==5.4.1 pytest-cov==2.8.1 -pytest-django==3.8.0 +pytest-django==3.9.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 diff --git a/requirements.txt b/requirements.txt index 32c20ad3c..5fadbb1bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ python-dateutil==2.8.1 -django==2.2.10 # pyup: <3.0 -django-auth-ldap==2.1.0 +django==2.2.12 +django-auth-ldap==2.1.1 # might remove this once we find out how the jsonapi extras_require work django-filter==2.2.0 django-multiselectfield==0.1.12 @@ -16,6 +16,6 @@ pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.8 pyexcel-ezodf==0.3.4 django-environ==0.4.5 -django-money==1.0 +django-money==1.1 python-redmine==2.2.1 uwsgi==2.0.18 From f7467203f404cbf6cf9f139f80dc8a6632eceb35 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 15 Apr 2020 09:49:21 +0200 Subject: [PATCH 674/980] fix(settings): make verified-email setting global --- timed/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/timed/settings.py b/timed/settings.py index 72988b65c..ce83c904d 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -254,9 +254,9 @@ def parse_admins(admins): default=resource_filename("timed.reports", "templates/workreport.ots"), ) -# Tracking: fields which should be included in email (when report was changed -# during verification) +# Tracking: Report fields which should be included in email (when report was +# changed during verification) TRACKING_REPORT_VERIFIED_CHANGES = env.list( "DJANGO_TRACKING_REPORT_VERIFIED_CHANGES", - default=default(["task", "comment", "not_billable"]), + default=["task", "comment", "not_billable"], ) From 50bb4ca16d16c55e63a7ca0153ba7ed81fc4b362 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 15 Apr 2020 10:08:18 +0200 Subject: [PATCH 675/980] feat: add setting to restrict work reports export size The `WORK_REPORTS_EXPORT_MAX_COUNT` setting restricts work reports export to the given count. This should fix cases where the (unfiltered) queryset is too big to handle. --- timed/reports/tests/test_work_report.py | 37 ++++++++++++++++++++++--- timed/reports/views.py | 26 ++++++++++++++--- timed/settings.py | 4 +++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index 7c7e52b0d..c16ae6e60 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -5,7 +5,7 @@ import ezodf import pytest from django.urls import reverse -from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST +from rest_framework import status from timed.projects.factories import CustomerFactory, ProjectFactory, TaskFactory from timed.reports.views import WorkReportViewSet @@ -35,7 +35,7 @@ def test_work_report_single_project(auth_client, django_assert_num_queries): "verified": 1, }, ) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK assert "1708-20170901-Customer_Name-Project.ods" in (res["Content-Disposition"]) content = io.BytesIO(res.content) @@ -62,7 +62,7 @@ def test_work_report_multiple_projects(auth_client, django_assert_num_queries): url = reverse("work-report-list") with django_assert_num_queries(4): res = auth_client.get(url, data={"user": auth_client.user.id, "verified": 0}) - assert res.status_code == HTTP_200_OK + assert res.status_code == status.HTTP_200_OK assert "20170901-WorkReports.zip" in (res["Content-Disposition"]) content = io.BytesIO(res.content) @@ -80,7 +80,7 @@ def test_work_report_multiple_projects(auth_client, django_assert_num_queries): def test_work_report_empty(auth_client): url = reverse("work-report-list") res = auth_client.get(url, data={"user": auth_client.user.id}) - assert res.status_code == HTTP_400_BAD_REQUEST + assert res.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.parametrize( @@ -102,3 +102,32 @@ def test_generate_work_report_name(db, customer_name, project_name, expected): name = view._generate_workreport_name(test_date, test_date, project) assert name == expected + + +@pytest.mark.freeze_time("2017-09-01") +@pytest.mark.parametrize( + "settings_count,given_count,expected_status", + [ + (-1, 9, status.HTTP_200_OK), + (0, 9, status.HTTP_200_OK), + (10, 9, status.HTTP_200_OK), + (9, 10, status.HTTP_400_BAD_REQUEST), + ], +) +def test_work_report_count( + auth_client, settings, settings_count, given_count, expected_status +): + user = auth_client.user + customer = CustomerFactory.create(name="Customer") + report_date = date(2017, 8, 17) + + settings.WORK_REPORTS_EXPORT_MAX_COUNT = settings_count + + project = ProjectFactory.create(customer=customer) + task = TaskFactory.create(project=project) + ReportFactory.create_batch(given_count, user=user, task=task, date=report_date) + + url = reverse("work-report-list") + res = auth_client.get(url, data={"user": auth_client.user.id, "verified": 0}) + + assert res.status_code == expected_status diff --git a/timed/reports/views.py b/timed/reports/views.py index e90b09baa..2e5ce307e 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -7,8 +7,10 @@ from django.conf import settings from django.db.models import F, Sum from django.db.models.functions import ExtractMonth, ExtractYear -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponse from ezodf import Cell, opendoc +from rest_framework import status +from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet from timed.mixins import AggregateQuerysetMixin @@ -268,11 +270,27 @@ def _create_workreport(self, from_date, to_date, today, project, reports, user): def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) + + if queryset.count() == 0: + return Response( + "No entries were selected. Make sure to clear unneeded filters.", + status=status.HTTP_400_BAD_REQUEST, + ) + # needed as we add items in reverse order to work report queryset = queryset.reverse() - count = queryset.count() - if count == 0: - return HttpResponseBadRequest() + + if ( + settings.WORK_REPORTS_EXPORT_MAX_COUNT > 0 + and queryset.count() > settings.WORK_REPORTS_EXPORT_MAX_COUNT + ): + return Response( + "Your request exceeds the maximum allowed entries ({0})".format( + settings.WORK_REPORTS_EXPORT_MAX_COUNT + ), + status=status.HTTP_400_BAD_REQUEST, + ) + params = self._parse_query_params(queryset, request) from_date = params.get("from_date") diff --git a/timed/settings.py b/timed/settings.py index ce83c904d..e82cfc2bb 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -254,6 +254,10 @@ def parse_admins(admins): default=resource_filename("timed.reports", "templates/workreport.ots"), ) +WORK_REPORTS_EXPORT_MAX_COUNT = env.int( + "DJANGO_WORK_REPORTS_EXPORT_MAX_COUNT", default=0 +) + # Tracking: Report fields which should be included in email (when report was # changed during verification) TRACKING_REPORT_VERIFIED_CHANGES = env.list( From c0dd29ecc6a23b74bc3efaa70080a413783091a7 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 8 Apr 2020 16:44:14 +0200 Subject: [PATCH 676/980] perf(reports): option to restrict export, handling of big querysets Make sure that reports-export does not crash. Add the settings REPORTS_EXPORT_MAX_COUNT to optionally restrict queryset to a certain length. Move cost_center and billing_type logic to query annotations to make it more performant. Return to-be-exported list a generator expression to not fill up memory. This also includes a fix where the cost center name used the task.project.name instead of task.cost_center.name. --- timed/settings.py | 2 + timed/tracking/tests/test_report.py | 82 ++++++++++++++++++++++++- timed/tracking/views.py | 92 ++++++++++++++++------------- 3 files changed, 132 insertions(+), 44 deletions(-) diff --git a/timed/settings.py b/timed/settings.py index e82cfc2bb..fcb55c30b 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -258,6 +258,8 @@ def parse_admins(admins): "DJANGO_WORK_REPORTS_EXPORT_MAX_COUNT", default=0 ) +REPORTS_EXPORT_MAX_COUNT = env.int("DJANGO_REPORTS_EXPORT_MAX_COUNT", default=0) + # Tracking: Report fields which should be included in email (when report was # changed during verification) TRACKING_REPORT_VERIFIED_CHANGES = env.list( diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 4229886e9..a362cf230 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -746,8 +746,52 @@ def test_report_list_filter_cost_center(auth_client): @pytest.mark.parametrize("file_type", ["csv", "xlsx", "ods"]) -def test_report_export(auth_client, file_type, django_assert_num_queries): - reports = ReportFactory.create_batch(2) +@pytest.mark.parametrize( + "project_cs_name,task_cs_name,project_bt_name", + [("Project cost center", "Task cost center", "Some billing type")], +) +@pytest.mark.parametrize( + "project_cs,task_cs,expected_cs_name", + [ + (True, True, "Task cost center"), + (True, False, "Project cost center"), + (False, True, "Task cost center"), + (False, False, ""), + ], +) +@pytest.mark.parametrize( + "project_bt,expected_bt_name", [(True, "Some billing type"), (False, "")] +) +def test_report_export( + auth_client, + django_assert_num_queries, + report, + task, + project, + cost_center_factory, + file_type, + project_cs, + task_cs, + expected_cs_name, + project_bt, + expected_bt_name, + project_cs_name, + task_cs_name, + project_bt_name, +): + report.task.project.cost_center = cost_center_factory(name=project_cs_name) + report.task.cost_center = cost_center_factory(name=task_cs_name) + report.task.project.billing_type.name = project_bt_name + report.task.project.billing_type.save() + + if not project_cs: + project.cost_center = None + if not task_cs: + task.cost_center = None + if not project_bt: + project.billing_type = None + project.save() + task.save() url = reverse("report-export") @@ -755,10 +799,42 @@ def test_report_export(auth_client, file_type, django_assert_num_queries): response = auth_client.get(url, data={"file_type": file_type}) assert response.status_code == status.HTTP_200_OK + book = pyexcel.get_book(file_content=response.content, file_type=file_type) # bookdict is a dict of tuples(name, content) sheet = book.bookdict.popitem()[1] - assert len(sheet) == len(reports) + 1 + + assert len(sheet) == 2 + assert sheet[1][-2:] == [expected_bt_name, expected_cs_name] + + +@pytest.mark.parametrize( + "settings_count,given_count,expected_status", + [ + (-1, 9, status.HTTP_200_OK), + (0, 9, status.HTTP_200_OK), + (10, 9, status.HTTP_200_OK), + (9, 10, status.HTTP_400_BAD_REQUEST), + ], +) +def test_report_export_max_count( + auth_client, + django_assert_num_queries, + report_factory, + task, + settings, + settings_count, + given_count, + expected_status, +): + settings.REPORTS_EXPORT_MAX_COUNT = settings_count + report_factory.create_batch(given_count, task=task) + + url = reverse("report-export") + + response = auth_client.get(url, data={"file_type": "csv"}) + + assert response.status_code == expected_status def test_report_update_bulk_verify_reviewer_multiple_notify( diff --git a/timed/tracking/views.py b/timed/tracking/views.py index e8662eee5..311d6ae6a 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,7 +1,8 @@ """Viewsets for the tracking app.""" import django_excel -from django.db.models import Q +from django.conf import settings +from django.db.models import Case, CharField, F, Q, Value, When from django.http import HttpResponseBadRequest from django.utils.translation import ugettext_lazy as _ from rest_framework import exceptions, status @@ -99,32 +100,6 @@ class ReportViewSet(ModelViewSet): "not_billable", ) - def _extract_cost_center(self, report): - """ - Extract cost center from given report. - - Cost center of task is prioritized higher than of - project. - """ - name = "" - - if report.task.project.cost_center: - name = report.task.project.cost_center.name - - if report.task.cost_center: - name = report.task.project.name - - return name - - def _extract_billing_type(self, report): - """Extract billing type from given report.""" - name = "" - - if report.task.project.billing_type: - name = report.task.project.billing_type.name - - return name - def update(self, request, *args, **kwargs): """Override so we can issue emails on update.""" @@ -236,6 +211,43 @@ def export(self, request): "task__project__cost_center", ) queryset = self.filter_queryset(queryset) + queryset = queryset.annotate( + cost_center=Case( + # Task cost center has precedence over project cost center + When( + task__cost_center__isnull=False, then=F("task__cost_center__name") + ), + When( + task__project__cost_center__isnull=False, + then=F("task__project__cost_center__name"), + ), + default=Value(""), + output_field=CharField(), + ) + ) + queryset = queryset.annotate( + billing_type=Case( + When( + task__project__billing_type__isnull=False, + then=F("task__project__billing_type__name"), + ), + default=Value(""), + output_field=CharField(), + ) + ) + if ( + settings.REPORTS_EXPORT_MAX_COUNT > 0 + and queryset.count() > settings.REPORTS_EXPORT_MAX_COUNT + ): + return Response( + _( + "Your request exceeds the maximum allowed entries ({0} > {1})".format( + queryset.count(), settings.REPORTS_EXPORT_MAX_COUNT + ) + ), + status=status.HTTP_400_BAD_REQUEST, + ) + colnames = [ "Date", "Duration", @@ -247,20 +259,18 @@ def export(self, request): "Billing Type", "Cost Center", ] - content = [ - [ - report.date, - report.duration, - report.task.project.customer.name, - report.task.project.name, - report.task.name, - report.user.username, - report.comment, - self._extract_billing_type(report), - self._extract_cost_center(report), - ] - for report in queryset - ] + + content = queryset.values_list( + "date", + "duration", + "task__project__customer__name", + "task__project__name", + "task__name", + "user__username", + "comment", + "billing_type", + "cost_center", + ) file_type = request.query_params.get("file_type") if file_type not in ["csv", "xlsx", "ods"]: From 56cdf30f711249c4af2f5b89b72994ab32d625cc Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 14 Apr 2020 17:04:16 +0200 Subject: [PATCH 677/980] chore: add missing translation strings --- timed/locale/en/LC_MESSAGES/django.po | 195 ++++++++++++++++++++++++-- 1 file changed, 186 insertions(+), 9 deletions(-) diff --git a/timed/locale/en/LC_MESSAGES/django.po b/timed/locale/en/LC_MESSAGES/django.po index e30af8af1..e016f9c49 100644 --- a/timed/locale/en/LC_MESSAGES/django.po +++ b/timed/locale/en/LC_MESSAGES/django.po @@ -7,24 +7,201 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-03-02 13:58+0100\n" +"POT-Creation-Date: 2020-04-14 17:04+0200\n" "PO-Revision-Date: 2017-03-02 13:59+0100\n" +"Last-Translator: \n" +"Language-Team: \n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Last-Translator: \n" -"Language-Team: \n" "X-Generator: Poedit 1.8.11\n" -#: timed/employment/admin.py:35 +#: timed/employment/admin.py:23 +msgid "Supervisor" +msgstr "" + +#: timed/employment/admin.py:24 +msgid "Supervisors" +msgstr "" + +#: timed/employment/admin.py:31 +msgid "Supervisee" +msgstr "" + +#: timed/employment/admin.py:32 +msgid "Supervisees" +msgstr "" + +#: timed/employment/admin.py:38 +msgid "Worktime per day in hours" +msgstr "" + +#: timed/employment/admin.py:58 timed/employment/serializers.py:248 msgid "The end date must be after the start date" msgstr "The end date must be after the start date" -#: timed/employment/admin.py:43 -msgid "A user can only have one active employment" -msgstr "A user can only have one active employment" - -#: timed/employment/admin.py:58 +#: timed/employment/admin.py:68 timed/employment/serializers.py:262 msgid "A user can't have multiple employments at the same time" msgstr "A user can't have multiple employments at the same time" + +#: timed/employment/admin.py:88 timed/subscription/admin.py:14 +msgid "Duration in hours" +msgstr "" + +#: timed/employment/admin.py:124 +msgid "Extra fields" +msgstr "" + +#: timed/employment/admin.py:129 +msgid "Disable selected users" +msgstr "" + +#: timed/employment/admin.py:134 +msgid "Enable selected users" +msgstr "" + +#: timed/employment/admin.py:139 +msgid "Disable staff status of selected users" +msgstr "" + +#: timed/employment/admin.py:144 +msgid "Enable staff status of selected users" +msgstr "" + +#: timed/employment/models.py:347 +msgid "last name" +msgstr "" + +#: timed/employment/views.py:87 timed/employment/views.py:99 +#, python-format +msgid "Transfer %(year)s" +msgstr "" + +#: timed/employment/views.py:138 timed/employment/views.py:197 +msgid "Date is invalid" +msgstr "" + +#: timed/employment/views.py:141 timed/employment/views.py:199 +msgid "Date filter needs to be set" +msgstr "" + +#: timed/employment/views.py:225 +msgid "User filter needs to be set" +msgstr "" + +#: timed/employment/views.py:233 +msgid "User is invalid" +msgstr "" + +#: timed/forms.py:31 +msgid "Enter a datetime.timedelta" +msgstr "" + +#: timed/models.py:17 +msgid "Monday" +msgstr "" + +#: timed/models.py:18 +msgid "Tuesday" +msgstr "" + +#: timed/models.py:19 +msgid "Wednesday" +msgstr "" + +#: timed/models.py:20 +msgid "Thursday" +msgstr "" + +#: timed/models.py:21 +msgid "Friday" +msgstr "" + +#: timed/models.py:22 +msgid "Saturday" +msgstr "" + +#: timed/models.py:23 +msgid "Sunday" +msgstr "" + +#: timed/projects/admin.py:47 timed/projects/admin.py:94 +msgid "Estimated time in hours" +msgstr "" + +#: timed/projects/admin.py:87 +msgid "Reviewer" +msgstr "" + +#: timed/projects/admin.py:88 +msgid "Reviewers" +msgstr "" + +#: timed/subscription/models.py:54 +msgid "password" +msgstr "" + +#: timed/tracking/serializers.py:47 +#, fuzzy +#| msgid "A user can only have one active employment" +msgid "A user can only have one active activity" +msgstr "A user can only have one active employment" + +#: timed/tracking/serializers.py:60 +msgid "An activity block may not end before it starts." +msgstr "" + +#: timed/tracking/serializers.py:108 +#, python-brace-format +msgid "Only owner may change {field}" +msgstr "" + +#: timed/tracking/serializers.py:140 +msgid "Only reviewer may verify reports." +msgstr "" + +#: timed/tracking/serializers.py:143 +msgid "You may only verifiy with your own user" +msgstr "" + +#: timed/tracking/serializers.py:147 +msgid "Report can't both be set as `review` and `verified`." +msgstr "" + +#: timed/tracking/serializers.py:294 +msgid "Only owner may change date" +msgstr "" + +#: timed/tracking/serializers.py:304 +msgid "Only owner may change absence type" +msgstr "" + +#: timed/tracking/serializers.py:322 +msgid "You can't create an absence on an unemployed day." +msgstr "" + +#: timed/tracking/serializers.py:328 +msgid "You can't create an absence on a public holiday" +msgstr "" + +#: timed/tracking/serializers.py:332 +msgid "You can't create an absence on a weekend" +msgstr "" + +#: timed/tracking/views.py:176 +msgid "Editable filter needs to be set for bulk update" +msgstr "" + +#: timed/tracking/views.py:185 +msgid "Reviewer filter needs to be set to verifying user" +msgstr "" + +#: timed/tracking/views.py:196 +msgid "Reports can't both be set as `review` and `verified`." +msgstr "" + +#: timed/tracking/views.py:244 +#, python-brace-format +msgid "Your request exceeds the maximum allowed entries ({0} > {1})" +msgstr "" From 0b34a60016b3d31170c8bdb95fd12a06c31845b7 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2020 00:12:55 +0000 Subject: [PATCH 678/980] chore(deps-dev): bump flake8-isort from 2.9.1 to 3.0.0 Bumps [flake8-isort](https://github.com/gforcada/flake8-isort) from 2.9.1 to 3.0.0. - [Release notes](https://github.com/gforcada/flake8-isort/releases) - [Changelog](https://github.com/gforcada/flake8-isort/blob/master/CHANGES.rst) - [Commits](https://github.com/gforcada/flake8-isort/compare/2.9.1...3.0.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 23fe60897..a040d8a8a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.2.1 flake8-deprecated==1.3 flake8-docstrings==1.5.0 -flake8-isort==2.9.1 +flake8-isort==3.0.0 flake8-string-format==0.3.0 ipdb==0.13.2 isort==4.3.21 From 9ba442c5377ef5a192d2f57dcf17c2b599ec15d0 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2020 00:17:39 +0000 Subject: [PATCH 679/980] chore(deps-dev): bump pytest-mock from 3.0.0 to 3.1.0 Bumps [pytest-mock](https://github.com/pytest-dev/pytest-mock) from 3.0.0 to 3.1.0. - [Release notes](https://github.com/pytest-dev/pytest-mock/releases) - [Changelog](https://github.com/pytest-dev/pytest-mock/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.0.0...v3.1.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 23fe60897..bbff2bf6b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,6 +19,6 @@ pytest-django==3.9.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 -pytest-mock==3.0.0 +pytest-mock==3.1.0 pytest-randomly==3.2.1 snapshottest==0.5.1 From ed452600a55bc4eb9281c0c43c08ece3380d5e24 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 23 Apr 2020 14:27:19 +0200 Subject: [PATCH 680/980] fix(admin): use django-money as installed app Somehow it got overlooked that django-money requires the "djmoney" entry in INSTALLED_APPS to be present. This causes the admin rendering to use django-money specific code instead of treating the values as simple decimals. See: https://github.com/django-money/django-money/issues/232 --- timed/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timed/settings.py b/timed/settings.py index fcb55c30b..a6d567832 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -60,6 +60,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django.contrib.staticfiles", "rest_framework", "django_filters", + "djmoney", "timed.employment", "timed.projects", "timed.tracking", From 462800c597d9fc26db79e241eb847b007a510dd5 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2020 00:12:01 +0000 Subject: [PATCH 681/980] chore(deps): bump pytz from 2019.3 to 2020.1 Bumps [pytz](https://github.com/stub42/pytz) from 2019.3 to 2020.1. - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/compare/release_2019.3...release_2020.1) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7ab18aba3..2e130b69e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ djangorestframework==3.11.0 djangorestframework-simplejwt==4.4.0 djangorestframework-jsonapi[django-filter]==3.1.0 psycopg2==2.8.5 -pytz==2019.3 +pytz==2020.1 pyexcel-webio==0.1.4 pyexcel-io==0.5.20 django-excel==0.0.10 From 33dfe687024a94c92b22961bbfefb09c6b04d773 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 6 May 2020 12:30:35 +0000 Subject: [PATCH 682/980] chore(deps-dev): bump pytest-randomly from 3.2.1 to 3.3.1 Bumps [pytest-randomly](https://github.com/pytest-dev/pytest-randomly) from 3.2.1 to 3.3.1. - [Release notes](https://github.com/pytest-dev/pytest-randomly/releases) - [Changelog](https://github.com/pytest-dev/pytest-randomly/blob/master/HISTORY.rst) - [Commits](https://github.com/pytest-dev/pytest-randomly/compare/3.2.1...3.3.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index bbff2bf6b..581fc58d1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,5 +20,5 @@ pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 pytest-mock==3.1.0 -pytest-randomly==3.2.1 +pytest-randomly==3.3.1 snapshottest==0.5.1 From 0806a40d68439214ecd2e6fc93b1a0ce5542e974 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 25 May 2020 00:17:06 +0000 Subject: [PATCH 683/980] chore(deps): bump python-redmine from 2.2.1 to 2.3.0 Bumps [python-redmine](https://github.com/maxtepkeev/python-redmine) from 2.2.1 to 2.3.0. - [Release notes](https://github.com/maxtepkeev/python-redmine/releases) - [Changelog](https://github.com/maxtepkeev/python-redmine/blob/master/CHANGELOG.rst) - [Commits](https://github.com/maxtepkeev/python-redmine/compare/v2.2.1...v2.3.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2e130b69e..a435b8292 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,5 +17,5 @@ pyexcel-xlsx==0.5.8 pyexcel-ezodf==0.3.4 django-environ==0.4.5 django-money==1.1 -python-redmine==2.2.1 +python-redmine==2.3.0 uwsgi==2.0.18 From ac59e9e680d675c1012e9b12c7b5fa5b973ab4d0 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 28 May 2020 11:38:38 +0200 Subject: [PATCH 684/980] feat: extend docker-compose to include proxy, keycloak Add keycloak and proxy to dev-setup. Since we are moving to OIDC auth, we need an actual OIDC provider to test in the local dev environment. This commit includes all the necessary bits in the docker-compose setup to get up and running locally. --- README.md | 12 +- dev-config/keycloak-config.json | 4264 +++++++++++++++++++++++++++++++ dev-config/nginx.conf | 31 + docker-compose.yml | 25 + 4 files changed, 4329 insertions(+), 3 deletions(-) create mode 100644 dev-config/keycloak-config.json create mode 100644 dev-config/nginx.conf diff --git a/README.md b/README.md index 584afc314..96614c7ab 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,21 @@ Timed timetracking software REST API built with Django After installing and configuring those requirements, you should be able to run the following commands to complete the installation: +Add the `timed.local` entries to your hosts file: +```bash +echo "127.0.0.1 timed.local" | sudo tee -a /etc/hosts +``` + +Then just start the docker-compose setup: ```bash make start ``` -You can now access the API at http://localhost:8000/api/v1 and the admin interface at http://localhost:8000/admin/ +This brings up complete local installation, including our [Timed Frontend](https://github.com/adfinis-sygroup/timed-frontend) project. -For end user interface have a look at our [Timed Frontend](https://github.com/adfinis-sygroup/timed-frontend) project. +You can visit it at [http://timed.local](http://timed.local). -The end user interface is included and is running under http://localhost:4200 +The API can be accessed at [http://timed.local/api/v1](http://timed.local/api/v1) and the admin interface at [http://timed.local/admin/](http://timed.local/admin/). ## Development diff --git a/dev-config/keycloak-config.json b/dev-config/keycloak-config.json new file mode 100644 index 000000000..2ef418564 --- /dev/null +++ b/dev-config/keycloak-config.json @@ -0,0 +1,4264 @@ +[ + { + "id": "master", + "realm": "master", + "displayName": "Keycloak", + "displayNameHtml": "

Keycloak
", + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 60, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "74803635-fcb5-4a70-9d65-4cb2a2018ada", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "master", + "attributes": {} + }, + { + "id": "6dff9f27-d194-44ed-b864-24b8a3473502", + "name": "admin", + "description": "${role_admin}", + "composite": true, + "composites": { + "realm": ["create-realm"], + "client": { + "timed-realm": [ + "view-events", + "manage-clients", + "manage-users", + "query-users", + "query-clients", + "view-clients", + "view-authorization", + "query-groups", + "manage-events", + "create-client", + "manage-identity-providers", + "manage-authorization", + "query-realms", + "manage-realm", + "view-users", + "view-identity-providers", + "impersonation", + "view-realm" + ], + "master-realm": [ + "manage-users", + "manage-realm", + "manage-events", + "create-client", + "impersonation", + "view-authorization", + "manage-identity-providers", + "view-clients", + "view-users", + "manage-clients", + "query-users", + "view-identity-providers", + "query-groups", + "query-clients", + "manage-authorization", + "view-realm", + "view-events", + "query-realms" + ] + } + }, + "clientRole": false, + "containerId": "master", + "attributes": {} + }, + { + "id": "4cf2c12f-380d-48a7-8c7a-e006807f923e", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "master", + "attributes": {} + }, + { + "id": "e9f08816-7ed3-4b72-9348-3cbc8534f7e7", + "name": "create-realm", + "description": "${role_create-realm}", + "composite": false, + "clientRole": false, + "containerId": "master", + "attributes": {} + } + ], + "client": { + "timed-realm": [ + { + "id": "90631cfc-bb65-4863-892b-9612dff0f1f9", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "e61882e7-7016-4375-9af9-573c96a01833", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "a879c80d-0d7e-48cc-a3a4-23dad366427e", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "63d85552-5557-488d-ad7e-0ddb58ca7445", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "f43e31eb-49e3-4048-b35c-d0c9ca6d74a0", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "f2b07ca3-1b38-4bec-8dbd-e795aa83c1ff", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "c09884b9-0bb5-4415-8673-dd52d064777e", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "e807576b-3335-4245-a913-8be5119f6a0c", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "b08cf7c0-b82b-44ed-944f-6e74359d183e", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "4420c074-3d67-44a2-8f8c-02aef033c65d", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "timed-realm": ["query-groups", "query-users"] + } + }, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "c8d41eb7-7aa6-4d23-8828-6433e7c0bfb4", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "timed-realm": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "c28a671e-41e3-4208-a318-5ccb9e425b85", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "bf89d2af-6827-4895-91c5-6033106b3847", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "7698d191-6124-449c-aa18-f181cc04269a", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "15872788-923d-427d-ac30-6d89cf78c12c", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "fef72afc-ae5d-4033-9771-5c24a725f6f3", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "b9574134-2196-43de-a4c9-8bae4ae29b22", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + }, + { + "id": "1c248024-cbc5-4b5b-8e31-9cb0b3e523dc", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "attributes": {} + } + ], + "timed-public": [], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "0427a7fb-273b-41b5-a308-28b235520366", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "ffd63e02-5832-41b5-a7ea-7d029ef6e786", + "attributes": {} + } + ], + "master-realm": [ + { + "id": "96233e00-73d9-403f-8498-bd2c001c113f", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "3f1d13cb-27a1-4545-818b-123a0397c7e8", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "44c6d85e-1b75-4550-8851-d39039b6b887", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "888d56ba-17aa-4418-a79f-f1a8b8a0059b", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "0bd71689-329d-4a3b-84ee-2781c2e772c6", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "b10c750e-1fe7-46be-a864-a934f88b202e", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "d736dad1-7094-412c-9b35-487a968c376c", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "f5813d1e-0e10-4edc-9862-b97f88139f7e", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "f0839344-a108-4df9-9e0d-d12701dd140e", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "515c59e9-ccc2-4ce9-902d-43e6f966d3df", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "e63e2463-979b-491b-909a-d1fe6e49914a", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "ef155b01-eeeb-4a3d-8ec0-64fbf8237821", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "17b53fa5-8a4c-4140-a4d2-084084df5eb1", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "master-realm": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "283de348-e608-4ed5-a985-8a58c23b8314", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "4ef7ffc6-a6dc-448f-b630-7ac64205bf68", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "e5d91425-97fe-47f9-919d-eb4531874e47", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "ecfbe461-2a38-4c96-b166-1b7a40a914c4", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + }, + { + "id": "6f0a3afc-ae1d-472a-9963-54f58c3efe73", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "master-realm": ["query-users", "query-groups"] + } + }, + "clientRole": true, + "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", + "attributes": {} + } + ], + "account": [ + { + "id": "84fe5615-f6aa-4a80-8bee-66f10a2c5957", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", + "attributes": {} + }, + { + "id": "a7d5481f-22d5-45fc-b36c-607f1d811349", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", + "attributes": {} + }, + { + "id": "c5321071-558d-43ba-b489-a419513d53bf", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", + "attributes": {} + }, + { + "id": "1754170e-d583-4dc2-8fe0-c9c3fe729080", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", + "attributes": {} + }, + { + "id": "ccc89bb6-0904-4a86-aa38-78fa658767a8", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", + "attributes": {} + }, + { + "id": "10e64579-317e-4ce1-9289-4fef88b29dbb", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRoles": ["offline_access", "uma_authorization"], + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": ["FreeOTP", "Google Authenticator"], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "26fac932-10ae-4536-bd43-06ea07921295", + "createdTimestamp": 1590596420690, + "username": "admin", + "enabled": true, + "totp": false, + "emailVerified": false, + "credentials": [ + { + "id": "02962cb4-31fa-44fd-ab1a-822e8eab8e11", + "type": "password", + "createdDate": 1590596420870, + "secretData": "{\"value\":\"XvUDxoGE9Kaz+7bwx4yCrcXD+Qju4EkF0dExsXKxgLao5Xvef9UGWGsQCQDBbSxdOC55CCdNDIf/9ieFI3QhTA==\",\"salt\":\"r5Mb9IqEYKFatadWcUBpXg==\"}", + "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["uma_authorization", "offline_access", "admin"], + "clientRoles": { + "account": ["view-profile", "manage-account"] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account"] + } + ] + }, + "clients": [ + { + "id": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "9d74d0fa-ad6f-4fc9-bffc-04f0d8d84ce2", + "defaultRoles": ["manage-account", "view-profile"], + "redirectUris": ["/realms/master/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d5401ddc-5896-45b0-b76c-2bafe2a1ef73", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "a94c8f6d-ad11-41e8-9e47-90f5c723e054", + "redirectUris": ["/realms/master/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "9537a26a-17da-49d9-a4da-600af7acf929", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "cf3c11c4-64a1-483c-af9d-e0c5eefa21fb", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "9802252b-7f2e-4b70-9cfa-1f5686ff6184", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "ffd63e02-5832-41b5-a7ea-7d029ef6e786", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "0ec59a47-4079-4a6b-91bb-5e97d9d07071", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "63680df5-5904-43ac-bf92-da985e330ad0", + "clientId": "master-realm", + "name": "master Realm", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "0f769832-9441-4574-9e58-021292024eb0", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "9d2a3d7c-f190-4357-84fc-e6aedefe1a8e", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/master/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "c6a648d3-92ec-4dda-9d3e-0b68923990cf", + "redirectUris": ["/admin/master/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "04f0f942-1e8c-4da0-abbb-258ff00b901c", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "6062468b-8837-47dc-ae94-7dbb6e50b38e", + "clientId": "timed-public", + "rootUrl": "http://timed.local", + "adminUrl": "http://timed.local", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "c29d9abc-e4ff-4d93-b016-36e492c0d37c", + "redirectUris": ["http://timed.local/*"], + "webOrigins": ["http://timed.local"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", + "clientId": "timed-realm", + "name": "timed Realm", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "4477e35b-1ad2-4176-91f2-13a5505a2c78", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "0a0f130e-8ff0-4bde-8cd4-6096321a804f", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "86e3f734-a36d-497f-9605-8ac794d8490a", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "3da973b0-298b-40c6-8586-8cf4a3f661ee", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "0ab08466-49a5-4def-af7f-6d9204907bf9", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "46cb3f89-fbe7-4d84-b7b5-17e7660c323b", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "211fe679-f1f0-4fb9-836b-ff37f2f916d9", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "3f1aaadc-ce8d-47f0-8b54-869858bbe0b6", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "54e16ba8-4736-4784-9f3b-bc0ce8d9f7d6", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "496b2ed4-8b60-4fe2-8a01-a363b8786584", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "2c07210c-eef4-46e2-9475-1ccc52de9132", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "998d966e-b254-43b2-8d38-fb98e64ca4f9", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "c3656857-d6d5-41a3-a0b9-2c5017dd03d0", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "52fd43d5-52c7-4019-aa61-49cd6649eea0", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "7f5d325f-e9e3-4f33-889d-fc60ecf7f41f", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "e30c1352-05c2-44ba-a438-784d1968497f", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "ffc6cc89-d967-464d-9180-cdc88f358664", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "9d67abc2-5426-4226-a609-8d6fcf18a93d", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "3742e516-53bb-4aa1-b6d4-c41f5c40d861", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "c01cfc17-c1a7-4cbb-b657-492cc78143a9", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "b91d0410-51c4-4453-98ab-62a0a3d72036", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "28d6eed7-808b-4988-8ca6-6d9e478fcb8c", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "8f04208d-10c5-460e-b9a7-885430aa667e", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "64bac4fb-9d19-4627-86df-e3b2485c9da8", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "4c5e932a-036a-4873-9e1f-0bbb53c3117d", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "80e85d07-2ee1-4b4b-82e9-b0a44b3f5a17", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "37fb39a0-c61a-4a20-9895-39ac6dbe1bd7", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "68f70dfc-7a17-4aa7-9a49-66d2a0116d12", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "8951f244-b14a-4280-b4e9-e3d902821bf6", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "4a3ae4eb-b255-4c7d-a3f0-09d646c63707", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "671f6484-b888-49f2-955d-65fd62ac6b62", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "87589f8c-3056-4310-acb8-8e134d60f2e2", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "ae4c81d0-593d-4f09-b6df-f76b0686d56d", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "19b0c16f-d4d2-42a5-a481-dd946e49b4b7", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "c9f8fb03-5301-47c9-b762-e25cc6cb3120", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "7bd83f9a-b47b-4ddd-89f2-8c95bdf655d6", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "xXSSProtection": "1; mode=block", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "eec0195d-d0e0-4e53-b9f0-d83715023ea2", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "saml-user-property-mapper" + ] + } + }, + { + "id": "1983a94a-1db2-48c3-87b2-5c3b1cfaea46", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "0f32e488-bdc0-4ef6-bc91-10b18614ffcb", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "f08b67c9-d36c-47a1-9e60-c8c560df5a9c", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "b8be5a3b-8050-4386-9864-b5c138a5ecb7", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "46239c4e-1d2b-44c9-aea1-7ea217ebe0ff", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "88b87930-bbc0-4584-8b4e-cc95037289c3", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "e303e4ca-9890-4f77-b5b1-d0300dd25edd", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "b21c8fa4-6a5b-47c2-b9c9-64b88fa75653", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEpAIBAAKCAQEA4fd0huWdL1kXFcwQVuTxyF5f74sTw2Vs69RGuJvU7UKOK1B5+SwN3j3nMVe/qj743HtY6FAho3jBTaGgpc99re4odUImIk/xCQsMDF9I6wBGTgm1h6W6sQKdKHBh9B+RlL5vW0EFEL57GAHQegUcMnMC835nD6qWj0cCSpJPACsqXsYyx+Jv3KpNL+/m0jGmxmt09kSv9vv9D3HAP4zBRTwmX7k3at6fU2sPB4TbdUQFyS2gDbSfzYa3uZHBlBL2yPYkmMLYZcVJRO+ThsG5rmHKE1SV2mYUVMjnl396cnK1x2NZVgQANgQHmZ7iLbOp+Yw3oUIrCeyluUNtTvfuvQIDAQABAoIBAQDJgc3Fej/I+G6wvnCXvMSshRSSXnj6V5lhWMTUXgrspdx4XeTXwmR/mr5v7yt5m3x7yfeH++VzjPz8yLSlCLqv/2DO6HVvRdDR2qsc4V/6SR1o/BmI5M7uiUEyzb1cYUaG2agePYZR3zuQNhX+qk3x40RvdXpcqyhmjtFJRN30a9z2ljzGjNWJdCjI8k7tHWM9OHaxfHb+FyrxQxACQincVei8DHoMfO2SNW93pTJ2EBUnmEnV62G+9wuIQFZjUWVwgpJf96m+WV5OHfSPb6mBc/3HsgSINOTsZcdQXb5HJkfTF4w1DJso/ihsHgWqaJYzAN8kgbCkScAUT4oZJSxVAoGBAPNdLnB7I2HujpE8JoXizA8I4HjpkDcU8UZ+F62NdxGsCjClkCC6GTxeEDEz7kW9Tig7dTk/CwpuTmE+apM04Dp0JQZLsPeMCa8xkIm5At8yq67yi0+ZB6nIdDhovGrwCo6vBxBw4VTrbxJYrnZ3tQIpIwC4N/m4i6ob922Q9XEDAoGBAO2zBrYY28n+hLZYUb9yo5zEMG4k/Ab7qEhQFbZaNKVH0XpwPK1Ul2JOHjjgNrz8t1buBAR3wo6nxuFrbr6I3WmDQLU7YKNWX/L/rjSdifhLmZWm3oSgP9mCczdtkkPj0rV58sh0498m4pbChUvTFnx8USX2DNZ05/GdJkzOjrU/AoGAVtGjQ5VqZgGI8t8WjyT9z09HZVtNi5j5CkDpiYyyMafCauBlroc1gYe9FxCDrHWAcHHlu+p1sd7wL1jpBGMUq0XL/5b5JxbaTZnNCpTqJV4aSWtVr6vURAmzDHyw2yWPXp+qUX8zo+vp0A27D6Bc/sxWJGeT8I6ZpLIdbwULyqkCgYAkyNW7DHHG+qpTBavw8q67LelIwlR2SC+ssSgLBj6rbUfPqNrbAAJFZk1rA9e0u28r9r2Ma3QiW3h9ngCPX+LT10oGQeAcpttGYab14YNed2SXMjGxWJNI99UYuM4vz2vmRa76sowpFn1uU0AJkesi7KIqO7+U2JakX2tz62tORQKBgQDKxiB93oL3ZTKAWbm05VAjBehdnZ7YNrlxEtYEqiY4ycPnyLzG9Z63K7XgSxNWk2suUwWBHqA6GPVCRgFHzBs7hJ5oqE5SPk57Ravf/jK5YE8PBO5M4mzHalve3qk3Ze8tVVVSbnZYL/0DM/6WXcSMYCbeAvElHvgmC3EDF2w73A==" + ], + "certificate": [ + "MIICmzCCAYMCBgFyVuz5HDANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjAwNTI3MTYxNjEwWhcNMzAwNTI3MTYxNzUwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDh93SG5Z0vWRcVzBBW5PHIXl/vixPDZWzr1Ea4m9TtQo4rUHn5LA3ePecxV7+qPvjce1joUCGjeMFNoaClz32t7ih1QiYiT/EJCwwMX0jrAEZOCbWHpbqxAp0ocGH0H5GUvm9bQQUQvnsYAdB6BRwycwLzfmcPqpaPRwJKkk8AKypexjLH4m/cqk0v7+bSMabGa3T2RK/2+/0PccA/jMFFPCZfuTdq3p9Taw8HhNt1RAXJLaANtJ/Nhre5kcGUEvbI9iSYwthlxUlE75OGwbmuYcoTVJXaZhRUyOeXf3pycrXHY1lWBAA2BAeZnuIts6n5jDehQisJ7KW5Q21O9+69AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHyHAvPOBLU0pZuJ1IVpkdLMMRqbBh0ZFD4rMy9Jdb5TVIScRfYh9pyqaA+2g6zRr5AZmitADou+OUj/7MUqPdXv0lOwy06YAzH/ImTVLUFguUP71XxwJgX5+o16wR43kf1HWXqH2SwmSmwIXNUgrruWkZn2pPPoAkDgd6Dpgx9CxHu+J6/8ngLYbzPE64xOtsQA0+pNXNgRWN844eihYLM5ThvoA9cnNGuvxs9QS+FNy0g9/zx8HaJfuI7TPbrvmEDV1Wo/u1owQsBoLPnyXOrPB4oUuQEQRGGFlp/O9L3irmXiBuDzZ1i7p2CzNmRpan9T15ni1nIuryzxWuC1rFM=" + ], + "priority": ["100"] + } + }, + { + "id": "74e51358-7cdd-4d46-82ea-2fc3588871db", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": ["caf49dff-5c1a-40c6-9517-158bfe952c2b"], + "secret": [ + "gOJfXvMa_5RCFneAhJQ_l4Zbp0Nvx5_TH5vAzea3CaxpcFd9M6aRJkxIYxW0ou1Nm5a9By2fmOHimVGvO52y3A" + ], + "priority": ["100"], + "algorithm": ["HS256"] + } + }, + { + "id": "10d84950-4825-4d9e-b611-2918ba2c1dc0", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": ["9f340647-7aa9-4ae5-b105-df634bef7fec"], + "secret": ["AStMPfNExU1W6b0UT6LaRw"], + "priority": ["100"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "6470c1c9-c60d-44c4-8855-32b16ddccffd", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "3173ffa5-b008-4703-90b7-0ebb015f2be7", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "adb6ee43-295d-4f16-941e-ef69a5d358af", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "a709b82a-d8d4-4b3c-b740-f035e91e981d", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "c7af99f9-0c98-40ea-b03a-29dc765dbe7e", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "2b0997a3-7021-4783-838e-bd66456e060e", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "d7dfd378-7e4c-47a7-babd-488f6df92bf4", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "659d2072-58f1-4aff-9fa7-2ee9b784f637", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "caab9ee8-0251-469e-9c31-0f1405f372ab", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9226714e-78ff-4f1d-89bc-bb4b19be7b35", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "c429f93d-aaae-4bee-ae46-d9ea45756c14", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "158fe2f0-0fe5-4cb9-809e-0fcf4f57aad3", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "95807305-f622-42a2-9a9d-dcf575c137b0", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "b1ce3f82-01d6-4f6e-8921-c4731429f682", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "f6d84af5-2172-409a-8f88-429e534d87dc", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "da67719a-dad8-4743-962e-3923b178b172", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "b835f804-19f1-46ad-8807-35c5ccf389c8", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "2468b1d2-1c96-43ae-b531-74786b329093", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "cde71745-bd53-49f3-ab96-ed3ca3f150e1", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "8a6a0b09-7405-4422-9e00-dabcea2e75b9", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "820299ec-5edc-4914-bad3-4e95e2a8f3cf", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "699a8e9f-5d25-48fc-996f-11b0a5bc2d66", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": {}, + "keycloakVersion": "10.0.1", + "userManagedAccessAllowed": false + }, + { + "id": "timed", + "realm": "timed", + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "9ff0d967-aa79-4bf2-8a90-7bd89f66d73c", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "timed", + "attributes": {} + }, + { + "id": "d99bb30b-f4d2-48f4-a053-706e42c4f7ee", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "timed", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "57a2fe69-be5f-444f-b65e-6c7f03f8d869", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-realms", + "create-client", + "manage-events", + "view-authorization", + "manage-authorization", + "view-identity-providers", + "query-groups", + "view-clients", + "manage-identity-providers", + "view-realm", + "query-clients", + "query-users", + "manage-clients", + "view-events", + "manage-users", + "impersonation", + "view-users", + "manage-realm" + ] + } + }, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "da8f521e-a54b-4870-802c-ea03dc9912b1", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "b631d90e-1240-45d7-9b80-2e56c476d682", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "d718e12e-eb6b-4876-be82-46d8404955cb", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "a0685af3-f0b8-4cca-ba68-43ac45f4d9a4", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "885d9844-528e-4ce5-a3e4-97d772c21f83", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "6df9ee52-f586-4464-b088-a7a9c1f5728f", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "f0c7d896-ea82-41f6-920d-6b3796e4c749", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "a5670fbb-438d-49b1-b27d-9ddcfe429bea", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "1b54aaa2-6b4d-4743-8967-af332be88f3c", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "67b6cc9f-8496-40d0-a20a-05a3956247dc", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "a88cfadc-008c-4be0-ab43-d41e86a477bd", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "3eceb96d-7c30-44c5-b24b-f187f2354a81", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "ee0d161a-ca6b-41c0-bc5f-649241374909", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "1f37d868-c309-4e66-9172-4f79c4717cfa", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "3bb25834-99d1-42da-a548-e1c2ac345f55", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "eec090c0-32de-4af5-a30c-6caddc24f234", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-users", "query-groups"] + } + }, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "978bf5c9-f072-483f-a812-1ac7435c32a3", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + }, + { + "id": "7bde3002-188f-4f9e-a2ae-791594539429", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes": {} + } + ], + "timed-public": [], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "9d18b1f6-81a4-45e8-b80d-a7f413435e35", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "1061a27d-9138-4b16-8d34-5aca38a99881", + "attributes": {} + } + ], + "account": [ + { + "id": "2e91b70b-71d0-43a4-aff2-d511ecbe336b", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes": {} + }, + { + "id": "d4cb3e12-deb9-4d08-998b-80d81d59e32a", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes": {} + }, + { + "id": "ed5043f6-5896-44c8-b5d1-604f3963dde4", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes": {} + }, + { + "id": "4f96be37-f919-49ee-82c5-5bd5b8b45216", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes": {} + }, + { + "id": "a66d557e-5e7b-40c2-9b42-9c92c6f7994a", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes": {} + }, + { + "id": "285e9a4b-523c-4455-b59c-8d599f5c0d77", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRoles": ["offline_access", "uma_authorization"], + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": ["FreeOTP", "Google Authenticator"], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account"] + } + ] + }, + "clients": [ + { + "id": "fca38486-0dca-4d9f-aebc-d219d641e179", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/timed/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "731474de-9346-457a-a514-888887f78683", + "defaultRoles": ["manage-account", "view-profile"], + "redirectUris": ["/realms/timed/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0953aa7e-fac7-43c9-aba9-83ce83d91c7c", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/timed/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "ec538ba5-bdd1-4f84-918d-90fc5e89874c", + "redirectUris": ["/realms/timed/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "2cd22dff-9478-416a-99fe-b2b70d18ca72", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "63a6baba-3d47-4308-af73-7c763fd31cfd", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "97bb38b7-d47c-4d37-88c2-7fed912b1f8b", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "1061a27d-9138-4b16-8d34-5aca38a99881", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "66d4120f-76dc-49fe-a958-7983be2aeee8", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "e4205e45-7122-4cb5-a3fd-c63cc58ee0a1", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "aa1b6e9c-92fe-426b-9eb0-23041d027c2d", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/timed/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "99ae8d54-620c-4bfb-9447-62dbbce5786b", + "redirectUris": ["/admin/timed/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "09f41dd1-e9f6-44eb-b847-0532ea9ea522", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "30472c69-7906-44be-acbc-619d1cb7d183", + "clientId": "timed-public", + "rootUrl": "http://timed.local", + "adminUrl": "http://timed.local", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "bde8e0d9-c4f8-4ab6-b1db-4724b85e8db0", + "redirectUris": ["http://timed.local/*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "role_list", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "430142db-2fac-4c47-a4d1-0893d0918da4", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "b964306d-914a-4442-8445-f3cc43bf4ef4", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "b86d691d-c6b7-45b2-993f-c4f3c6ed9f21", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "caf47f60-07c5-4975-a2e2-709aa6aee27e", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "6521805d-46b1-4fb9-988b-ae4d13e1b8e5", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "a28910e0-e6b4-4d2b-9fa0-bb6b2c6ea78a", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d0b1f606-d23e-4762-9d29-24a1f060c879", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "349b94d1-e9d9-464e-93da-fdc6c715037f", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "60b03eb6-b050-4454-a337-f8928575ee7d", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "fca7028e-0e94-4c09-988e-e661d46482b9", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a1feecd1-a101-444a-b8d1-c4a459e7ea6d", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "301418c7-ab4e-4a8b-a13f-331e1ab094f3", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "d8c1471f-1e13-4e37-9639-40257ca784cc", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "8e2378db-ebe8-4141-af99-a7d8cbfb31bd", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "9aea5061-7e49-4a9f-9dd6-b0ec6aa31a33", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "63233679-5038-434f-9cf1-1b4cac25eb9d", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "0e4034e3-4bf8-45a6-9b03-5fe58406a235", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "db51ecec-798e-4407-a750-2eba3c74bf5c", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "f66647a8-cc03-4936-a4c8-a2d43f2fb605", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "a581ee00-615b-4ae2-9123-14889580a95b", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "30bec6a9-f8c3-4a50-9a1b-d07cae3df8d8", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "16ac837d-ff47-4052-9a68-8ed6b399ec2b", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "82c27e74-153a-47b2-8148-71af806b1f99", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "83701e99-d46b-4ddb-9e87-696d41a97984", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "69977a6d-5238-4385-a922-0c1b99f1c15a", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "aa8e440f-7812-408f-b55c-a154337aae2d", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "84c655de-34d7-4d58-a63c-9c78475da136", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "8e350b44-837b-42f1-9cf2-445f282ea9da", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "78f6f58f-4cd6-4635-ba1c-e8f7c9ec29f1", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "93d7c992-6651-4300-9b85-832e8929bce4", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "e7134f4a-d194-4dfc-bf3e-40de3b3bf087", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "936536a3-9b53-44a5-ad90-1e82871c93e8", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "275e146a-73cc-4930-a752-2bd18764f65b", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "17745df8-9dda-4ded-b2e1-10c10892a341", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "15a58940-f2e8-4032-bdf0-f5d586fa86e1", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "0c68916d-29e5-4bc3-8f34-66d3e074d0ba", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "01017e9d-ccee-4682-a43d-dedc779456df", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-role-list-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "04a61e10-fc46-4e0c-ad0a-3eec163d6e24", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "aae5ad0e-f00b-4ebf-8b7f-dac3e020d5d5", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "cae2d534-9164-4760-a10d-19cfdf36ac0d", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "cb3d8398-9fda-423a-bc1a-9504ead9d10f", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "saml-role-list-mapper", + "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "65750361-be2e-41cc-ae63-1abdda285720", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "f1669b22-e29d-4eda-97b3-f4ab3ec57673", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "ffd05084-ba7b-4d5f-bec3-9a2c9cabb7eb", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEowIBAAKCAQEAmKi1AOCgn2rmzdroi54yETw7vo7sVFoQsKYf3L68TgLMjHxsjZLj7o6aVi9wv3rZ0krm7JBD3nxcGSPyEkBGYwaGzO0qxSnjkEfqRFxOODjWcQglcfycY3JyQE8Uz2bJ45BQ3lebviP6AEF3RW0xmuiQ6BKr26sK8iRa7ivZH2SbLsKMVlR/7kCHNGnCnCqWXuCmMMItGT0eqclbnT0BBKBN6aTou7Nh95Uj3Iwh9EfPUIQjnrRK9YOCdh3mjvuadojiPWUvRkVOOGg9yBU32J/WKJUbO4qp5VsJGkSwBPgN5rUa0np1vP2WwDihPIFbQMUqm+ZBOOCbhbYMx4KzXwIDAQABAoIBAQCOdNi74eJiAZsyPHbHWy+zn7bM44isSoPKpKuVDjSgw8Hn03BlSM8E3fQuOwUG2niL4jPOS+3Zn8k9+Ko719kXLY77itJfvPBLwqBdfJnNo1SRlB2FWksCDlmJo4Jy7KO3hQPCCJUggWgZdv37PqOMwDwBJPNVAS8suTpViXuK66EcDsB2m4R+rRXqVXz2w20CSCT1zathxEIQsBBxKR4lJHBgCE6GDwGf7SZGIxLgQhnnYUib3rSh3RyHoexPwPUjhQmJIVPWK1krRwpW93QXGL0wWXAvROyM0kg3Qd8evqqfkPHtO+zqnYa632l93DhgeESykTImxTykPcHxz2kJAoGBAM2EXe4CiLGLbjmYxatXOpQmI8rmsskvL6FE5tjYHh2XF57U3OoNhdHyU0H7Qz8z3mRj2irwRHG6QhjH+yU1TSWgiaPLc9rZ7PE4tRCOkG1c9vjMdvexOrXs16FUY1xWXMwVhvKlZOO3t1D7yzQvzibYyrkvHST61u+bf+fbtIW1AoGBAL4ocN1HPhcro9DTyiK/AJgntnwNA4jv6QwzvK+hPqd+DJoVHhBghbHhhdpZKiPRC/2nCLuiBSNmXzG2Awapn6JoBely319InxvKrPZOFcvOxKVHw1XDtrkUaL6yM6EgvLsHU8yR8Ov6gaLdg2NbpfA+VXL1KxvVDois8s/+hgFDAoGAWMiOK3w8wTaS757oBhUw4T94xvbS1cbktK6na5YxrGbRdXRP22zsGr6s6Rw6+NrXgFcCsPoLF3Z3h20dOf3EzjSEQZZq/miWy77LudNc4WH/74uk+Ww/CMjAfpmOMx28CQ5jtf9tjlKXhwy/xFPCo1WUflu0I32ZzPlIUEnBuuECgYAhqeMhKUWSsIUVqQi10f528UDbASrJCT/GizoyFWeUGzp75JUn7Q5+CSC7IOHW6WEoDHP9U5d5Rtw/Xqt2eHzsMWIqi82DfsW8E8s+51/wbrBdWjD4c+dbKIPKjp2ZPsRqj8eEBaoS/IwKmxBxfH4J498YtNJm4Pbrt0JdFAABJQKBgHFqGu3a5cCbcuo0wDPyvibq1BcFVQSgXHBhztu8LPRHTo3BzACrVEN3MX3H+uzafb0YHDb/tpPYw/RWC8luE1CrKSr9l/qG6r3nNaTnG7rxj2dRffsyXuUwSvc+DCu1aTR2Zk/uMoH1pr66cM5YjwVzQcuPA820+1BXyFsyGDy9" + ], + "certificate": [ + "MIICmTCCAYECBgFyVvIFkjANBgkqhkiG9w0BAQsFADAQMQ4wDAYDVQQDDAV0aW1lZDAeFw0yMDA1MjcxNjIxNDFaFw0zMDA1MjcxNjIzMjFaMBAxDjAMBgNVBAMMBXRpbWVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmKi1AOCgn2rmzdroi54yETw7vo7sVFoQsKYf3L68TgLMjHxsjZLj7o6aVi9wv3rZ0krm7JBD3nxcGSPyEkBGYwaGzO0qxSnjkEfqRFxOODjWcQglcfycY3JyQE8Uz2bJ45BQ3lebviP6AEF3RW0xmuiQ6BKr26sK8iRa7ivZH2SbLsKMVlR/7kCHNGnCnCqWXuCmMMItGT0eqclbnT0BBKBN6aTou7Nh95Uj3Iwh9EfPUIQjnrRK9YOCdh3mjvuadojiPWUvRkVOOGg9yBU32J/WKJUbO4qp5VsJGkSwBPgN5rUa0np1vP2WwDihPIFbQMUqm+ZBOOCbhbYMx4KzXwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQA/52H5X2MAH6aw445RbeU6fVYnKJu4hBkcIGHPjKR++Gq54M6bcbnNKNunhVbGZUqdPHX4+ktnQSZauq2hd9HRcezhL3OlmtEnLNW8BoFPaB11gmdjOiVQGm6iJsSJGxrrO7q3YzY+IB/ZTZlWmuOGnUDprFxDv0LR3M8X0ls0ygmw4/CmXZ6Fhg43Ey27ZArlSmzohAq8YGpV8HEcfQFp/h6+B0hgMbufHXvXOSF3Rj7Es1XXrusOhTGPEVv3qC4AaSHSjVVk8C18gFm5hpUjwN7O5u/lSov7WV5iSJMcFnSOxIv9+CboMQehtBpIvczEmRDE+r4/hq5mlpnc/ba9" + ], + "priority": ["100"] + } + }, + { + "id": "6fb96b2c-f93d-47c0-bd8b-5e9568f71a43", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": ["14d96e27-1ef3-4c48-bbc3-95968353669f"], + "secret": [ + "HjQ76-HhFsIZkDoEkmVxlVYCoXwFysEsmhK3LsyyMaI9FVyuc0Tb4kYuP2f5Pz--NYxPcvJr3Q8M8OwN9kHSTw" + ], + "priority": ["100"], + "algorithm": ["HS256"] + } + }, + { + "id": "6eba3413-03c0-4359-8b90-471f2628550e", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": ["e99ccacc-1cf8-42e3-ac6a-23553f29efd6"], + "secret": ["FpK8RqyaSzxuyi83SsilRA"], + "priority": ["100"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "de99607a-398d-4e10-9ced-fe7df827d7e1", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "37f9788d-fab3-4cbf-b16d-b6753cf4669b", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "c99d9202-c15e-4b14-8d7b-d4e6c28a4bd9", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "b9c16a89-3ee9-41de-9994-e64fd2e94b20", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "6c3951a3-fbe6-4412-a1c3-e00fb33a4b7c", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "e89e9389-cf60-42d1-8f9f-50d06ef69746", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "b4341d72-fbd4-4aa1-bdd1-8211a4344013", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "ce5ce09a-8423-4730-8de9-978c0981e372", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "64cda71a-eb38-4f52-bc85-1a1293034f45", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "e4a47fd1-469c-4774-b010-66acfe1563b9", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "7fd482ae-b75b-4d86-88c9-8a6847c54b77", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "1fe97e87-f958-46ab-abe3-5e8765a67384", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "0496f821-25c0-4537-93d5-821554974d09", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "7f4f7c4f-b832-4199-b7f0-ccd1fc8c1c44", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "8a74e09d-1033-4740-b408-f5c9ded1b94d", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "88df51d9-ff69-4871-8f7d-ef57a9d40be8", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "cbc9b825-ce60-4ed7-8f8d-7ed0b002c63d", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "0a2f422d-623a-49a0-93d1-6ef8ce59172e", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "cb16f7b6-ef0b-4f72-a1a9-126e18c0dbf8", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "08085618-ea36-4186-b390-2724a3ca2a0c", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "878df3e1-ea29-4651-a968-b1112bb9c0b3", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "2dd45740-0f70-4364-9df4-71ecfb294bc7", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": {}, + "keycloakVersion": "10.0.1", + "userManagedAccessAllowed": false + } +] diff --git a/dev-config/nginx.conf b/dev-config/nginx.conf new file mode 100644 index 000000000..21cacb433 --- /dev/null +++ b/dev-config/nginx.conf @@ -0,0 +1,31 @@ +resolver 127.0.0.11 valid=2s; + +server { + listen 80; + listen [::]:80; + + server_name timed.local; + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_http_version 1.1; + proxy_redirect off; + + client_max_body_size 50m; + + # db-flush may not be exposed in PRODUCTION! + location ~ ^/(api|admin|db-flush)/ { + set $backend http://backend; + proxy_pass $backend; + } + + location ~ ^/auth/ { + set $keycloak http://keycloak:8080; + proxy_pass $keycloak; + } + + location / { + set $frontend http://frontend; + proxy_pass $frontend; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ec2bd9f93..bded06878 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,5 +30,30 @@ services: - ENV=docker - STATIC_ROOT=/var/www/static + keycloak: + image: jboss/keycloak:10.0.1 + volumes: + - ./timed-realm.json:/etc/keycloak/keycloak-config.json:ro + depends_on: + - db + environment: + - DB_VENDOR=postgres + - DB_ADDR=db + - DB_USER=timed + - DB_DATABASE=timed + - DB_PASSWORD=timed + - PROXY_ADDRESS_FORWARDING=true + - KEYCLOAK_USER=admin + - KEYCLOAK_PASSWORD=admin + - KEYCLOAK_IMPORT=/etc/keycloak/keycloak-config.json + + proxy: + image: nginx:1.17.10-alpine + ports: + - 80:80 + volumes: + - ./dev-config/nginx.conf:/etc/nginx/conf.d/default.conf:ro + volumes: dbdata: + kcdata: From 0308cd5a486421246ef3fc4c2991011911a2259c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2020 00:14:28 +0000 Subject: [PATCH 685/980] chore(deps-dev): bump pytest from 5.4.1 to 5.4.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.4.1 to 5.4.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.4.1...5.4.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f0ee54ec0..cb388e87a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.13.2 isort==4.3.21 mockldap==0.3.0.post1 pdbpp==0.10.2 -pytest==5.4.1 +pytest==5.4.3 pytest-cov==2.8.1 pytest-django==3.9.0 pytest-env==0.6.2 From 6fc925a3dda87547479412a59fb481b9d916d194 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 3 Jun 2020 14:33:23 +0200 Subject: [PATCH 686/980] fix: docker-compose setup Use correct path for keycloak config. --- docker-compose.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index bded06878..fa8fdab1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: keycloak: image: jboss/keycloak:10.0.1 volumes: - - ./timed-realm.json:/etc/keycloak/keycloak-config.json:ro + - ./dev-config/keycloak-config.json:/etc/keycloak/keycloak-config.json:ro depends_on: - db environment: @@ -43,9 +43,7 @@ services: - DB_DATABASE=timed - DB_PASSWORD=timed - PROXY_ADDRESS_FORWARDING=true - - KEYCLOAK_USER=admin - - KEYCLOAK_PASSWORD=admin - - KEYCLOAK_IMPORT=/etc/keycloak/keycloak-config.json + command: ["-Dkeycloak.migration.action=import", "-Dkeycloak.migration.provider=singleFile", "-Dkeycloak.migration.file=/etc/keycloak/keycloak-config.json", "-b", "0.0.0.0"] proxy: image: nginx:1.17.10-alpine @@ -56,4 +54,3 @@ services: volumes: dbdata: - kcdata: From f1aaa1b17058ebac68b359d7bfe4641e471621a0 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2020 00:13:47 +0000 Subject: [PATCH 687/980] chore(deps-dev): bump pytest-mock from 3.1.0 to 3.1.1 Bumps [pytest-mock](https://github.com/pytest-dev/pytest-mock) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/pytest-dev/pytest-mock/releases) - [Changelog](https://github.com/pytest-dev/pytest-mock/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.1.0...v3.1.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f0ee54ec0..7f132a0ed 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,6 +19,6 @@ pytest-django==3.9.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 -pytest-mock==3.1.0 +pytest-mock==3.1.1 pytest-randomly==3.3.1 snapshottest==0.5.1 From d94572b4d437078f734f2785bb5cfc5cd5a685f6 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2020 00:16:03 +0000 Subject: [PATCH 688/980] chore(deps): bump django-filter from 2.2.0 to 2.3.0 Bumps [django-filter](https://github.com/carltongibson/django-filter) from 2.2.0 to 2.3.0. - [Release notes](https://github.com/carltongibson/django-filter/releases) - [Changelog](https://github.com/carltongibson/django-filter/blob/master/CHANGES.rst) - [Commits](https://github.com/carltongibson/django-filter/compare/2.2.0...2.3.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2e130b69e..ed2c224b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ python-dateutil==2.8.1 django==2.2.12 django-auth-ldap==2.1.1 # might remove this once we find out how the jsonapi extras_require work -django-filter==2.2.0 +django-filter==2.3.0 django-multiselectfield==0.1.12 djangorestframework==3.11.0 djangorestframework-simplejwt==4.4.0 From 88a5b6a2009947dfa87a5a1580534d252caa79d4 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2020 00:13:59 +0000 Subject: [PATCH 689/980] chore(deps-dev): bump flake8 from 3.7.9 to 3.8.3 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.7.9 to 3.8.3. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.7.9...3.8.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f0ee54ec0..87f32394b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==19.10b0 coverage==5.1 factory-boy==2.12.0 -flake8==3.7.9 +flake8==3.8.3 flake8-blind-except==0.1.1 flake8-debugger==3.2.1 flake8-deprecated==1.3 From 58d9595c27812b7d658e29155d7d1fdbdfd1cc7c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2020 11:44:40 +0000 Subject: [PATCH 690/980] chore(deps): bump uwsgi from 2.0.18 to 2.0.19 Bumps [uwsgi](https://github.com/unbit/uwsgi-docs) from 2.0.18 to 2.0.19. - [Release notes](https://github.com/unbit/uwsgi-docs/releases) - [Commits](https://github.com/unbit/uwsgi-docs/commits) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a435b8292..8998d7ada 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ pyexcel-ezodf==0.3.4 django-environ==0.4.5 django-money==1.1 python-redmine==2.3.0 -uwsgi==2.0.18 +uwsgi==2.0.19 From ba9fb4e80591f7f890243cd3d1854dcae154a871 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2020 11:46:43 +0000 Subject: [PATCH 691/980] chore(deps-dev): bump pytest-randomly from 3.3.1 to 3.4.0 Bumps [pytest-randomly](https://github.com/pytest-dev/pytest-randomly) from 3.3.1 to 3.4.0. - [Release notes](https://github.com/pytest-dev/pytest-randomly/releases) - [Changelog](https://github.com/pytest-dev/pytest-randomly/blob/master/HISTORY.rst) - [Commits](https://github.com/pytest-dev/pytest-randomly/compare/3.3.1...3.4.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7f132a0ed..a9514b801 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,5 +20,5 @@ pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 pytest-mock==3.1.1 -pytest-randomly==3.3.1 +pytest-randomly==3.4.0 snapshottest==0.5.1 From ddd2833671138f67326c9f676079b0bdefadc001 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2020 11:47:38 +0000 Subject: [PATCH 692/980] chore(deps-dev): bump pytest-cov from 2.8.1 to 2.10.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.8.1 to 2.10.0. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.8.1...v2.10.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7d934a8e1..dab9ef17d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==4.3.21 mockldap==0.3.0.post1 pdbpp==0.10.2 pytest==5.4.3 -pytest-cov==2.8.1 +pytest-cov==2.10.0 pytest-django==3.9.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 From 2b46f71e9b04e3d8a5df028dddf87ab9fce9f86b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2020 11:50:42 +0000 Subject: [PATCH 693/980] chore(deps): [security] bump django from 2.2.12 to 2.2.13 Bumps [django](https://github.com/django/django) from 2.2.12 to 2.2.13. **This update includes security fixes.** - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.12...2.2.13) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5e7127dd9..78a0c649f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.1 -django==2.2.12 +django==2.2.13 django-auth-ldap==2.1.1 # might remove this once we find out how the jsonapi extras_require work django-filter==2.3.0 From 92d70ae5d9feb5319b6e2cf5e896cd8f3ecac0c4 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 22 May 2020 17:58:21 +0200 Subject: [PATCH 694/980] feat: remove ldap auth, simplify test client setup Remove support for LDAP auth, in order for preparing to OIDC auth instead. Until now, we relied on a custom `JSONAPIClient` which is unnecessary, hence we drop it. Use the standard rest_framework `APIClient` instead with `force_authenticate`, cutting out the authentication middleware during testing. --- requirements.txt | 2 - timed/conftest.py | 109 +++++++----------- .../employment/tests/test_absence_balance.py | 4 +- timed/employment/tests/test_user.py | 17 +-- .../employment/tests/test_worktime_balance.py | 8 +- timed/projects/tests/test_project.py | 4 +- .../reports/tests/test_customer_statistic.py | 4 +- timed/reports/tests/test_project_statistic.py | 2 +- timed/reports/tests/test_task_statistic.py | 2 +- timed/reports/tests/test_work_report.py | 4 +- timed/settings.py | 32 ++--- timed/tests/client.py | 90 --------------- timed/tests/test_client.py | 20 ---- timed/tracking/tests/test_report.py | 2 +- timed/urls.py | 3 - 15 files changed, 69 insertions(+), 234 deletions(-) delete mode 100644 timed/tests/client.py delete mode 100644 timed/tests/test_client.py diff --git a/requirements.txt b/requirements.txt index 78a0c649f..009f06aae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,9 @@ python-dateutil==2.8.1 django==2.2.13 -django-auth-ldap==2.1.1 # might remove this once we find out how the jsonapi extras_require work django-filter==2.3.0 django-multiselectfield==0.1.12 djangorestframework==3.11.0 -djangorestframework-simplejwt==4.4.0 djangorestframework-jsonapi[django-filter]==3.1.0 psycopg2==2.8.5 pytz==2020.1 diff --git a/timed/conftest.py b/timed/conftest.py index 8657d3236..ad8138892 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -1,15 +1,14 @@ import inspect -import mockldap import pytest from django.contrib.auth import get_user_model from factory.base import FactoryMetaClass from pytest_factoryboy import register +from rest_framework.test import APIClient from timed.employment import factories as employment_factories from timed.projects import factories as projects_factories from timed.subscription import factories as subscription_factories -from timed.tests.client import JSONAPIClient from timed.tracking import factories as tracking_factories @@ -25,48 +24,9 @@ def register_module(module): register_module(tracking_factories) -@pytest.fixture(autouse=True, scope="session") -def ldap_directory(): - top = ("o=test", {"o": "test"}) - people = ("ou=people,o=test", {"ou": "people"}) - groups = ("ou=groups,o=test", {"ou": "groups"}) - ldapuser = ( - "uid=ldapuser,ou=people,o=test", - { - "uid": ["ldapuser"], - "objectClass": [ - "person", - "organizationalPerson", - "inetOrgPerson", - "posixAccount", - ], - "userPassword": ["Test1234!"], - "uidNumber": ["1000"], - "gidNumber": ["1000"], - "givenName": ["givenName"], - "mail": ["ldapuser@example.net"], - "sn": ["LdapUser"], - }, - ) - - directory = dict([top, people, groups, ldapuser]) - mock = mockldap.MockLdap(directory) - mock.start() - - yield - - mock.stop() - - -@pytest.fixture -def client(db): - return JSONAPIClient() - - @pytest.fixture -def auth_client(db): - """Return instance of a JSONAPIClient that is logged in as test user.""" - user = get_user_model().objects.create_user( +def auth_user(db): + return get_user_model().objects.create_user( username="user", password="123qweasd", first_name="Test", @@ -75,43 +35,58 @@ def auth_client(db): is_staff=False, ) - client = JSONAPIClient() - client.user = user - client.login("user", "123qweasd") - return client - @pytest.fixture -def admin_client(db): - """Return instance of a JSONAPIClient that is logged in as a staff user.""" - user = get_user_model().objects.create_user( - username="user", +def admin_user(db): + return get_user_model().objects.create_user( + username="admin", password="123qweasd", - first_name="Test", + first_name="Admin", last_name="User", is_superuser=False, is_staff=True, ) - client = JSONAPIClient() - client.user = user - client.login("user", "123qweasd") - return client - @pytest.fixture -def superadmin_client(db): - """Return instance of a JSONAPIClient that is logged in as superuser.""" - user = get_user_model().objects.create_user( - username="user", +def superadmin_user(db): + return get_user_model().objects.create_user( + username="superadmin", password="123qweasd", - first_name="Test", + first_name="Superadmin", last_name="User", - is_staff=True, is_superuser=True, + is_staff=True, ) - client = JSONAPIClient() - client.user = user - client.login("user", "123qweasd") + +@pytest.fixture +def client(): + return APIClient() + + +@pytest.fixture +def auth_client(auth_user): + """Return instance of a APIClient that is logged in as test user.""" + client = APIClient() + client.force_authenticate(user=auth_user) + client.user = auth_user + return client + + +@pytest.fixture +def admin_client(admin_user): + """Return instance of a APIClient that is logged in as a staff user.""" + client = APIClient() + client.force_authenticate(user=admin_user) + client.user = admin_user + return client + + +@pytest.fixture +def superadmin_client(superadmin_user): + """Return instance of a APIClient that is logged in as superuser.""" + client = APIClient() + client.force_authenticate(user=superadmin_user) + client.user = superadmin_user return client diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py index 90174ac81..8111777a6 100644 --- a/timed/employment/tests/test_absence_balance.py +++ b/timed/employment/tests/test_absence_balance.py @@ -30,7 +30,7 @@ def test_absence_balance_full_day(auth_client, django_assert_num_queries): url = reverse("absence-balance-list") - with django_assert_num_queries(7): + with django_assert_num_queries(6): result = auth_client.get( url, data={ @@ -73,7 +73,7 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): AbsenceFactory.create(date=day, user=user, type=absence_type) url = reverse("absence-balance-list") - with django_assert_num_queries(12): + with django_assert_num_queries(11): result = auth_client.get( url, data={ diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 637d97b16..6efaf22ce 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -1,7 +1,6 @@ from datetime import date, timedelta import pytest -from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework import status @@ -20,27 +19,19 @@ def test_user_list_unauthenticated(client): assert response.status_code == status.HTTP_401_UNAUTHORIZED -def test_user_update_unauthenticated(client): +def test_user_update_unauthenticated(client, db): user = UserFactory.create() url = reverse("user-detail", args=[user.id]) response = client.patch(url) assert response.status_code == status.HTTP_401_UNAUTHORIZED -def test_user_login_ldap(client): - client.login("ldapuser", "Test1234!") - user = get_user_model().objects.get(username="ldapuser") - assert user.first_name == "givenName" - assert user.last_name == "LdapUser" - assert user.email == "ldapuser@example.net" - - -def test_user_list(auth_client, django_assert_num_queries): +def test_user_list(db, auth_client, django_assert_num_queries): UserFactory.create_batch(2) url = reverse("user-list") - with django_assert_num_queries(8): + with django_assert_num_queries(7): response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -135,7 +126,7 @@ def test_user_delete_superuser(superadmin_client): assert response.status_code == status.HTTP_204_NO_CONTENT -def test_user_delete_with_reports_superuser(superadmin_client): +def test_user_delete_with_reports_superuser(superadmin_client, db): """Test that user with reports may not be deleted.""" user = UserFactory.create() ReportFactory.create(user=user) diff --git a/timed/employment/tests/test_worktime_balance.py b/timed/employment/tests/test_worktime_balance.py index 58e1aee29..72d5076b4 100644 --- a/timed/employment/tests/test_worktime_balance.py +++ b/timed/employment/tests/test_worktime_balance.py @@ -24,7 +24,7 @@ def test_worktime_balance_create(auth_client): def test_worktime_balance_no_employment(auth_client, django_assert_num_queries): url = reverse("worktime-balance-list") - with django_assert_num_queries(4): + with django_assert_num_queries(3): result = auth_client.get( url, data={"user": auth_client.user.id, "date": "2017-01-01"} ) @@ -89,7 +89,7 @@ def test_worktime_balance_with_employments(auth_client, django_assert_num_querie args=["{0}_{1}".format(auth_client.user.id, end_date.strftime("%Y-%m-%d"))], ) - with django_assert_num_queries(12): + with django_assert_num_queries(11): result = auth_client.get(url) assert result.status_code == status.HTTP_200_OK @@ -180,7 +180,7 @@ def test_worktime_balance_list_last_reported_date_no_reports( url = reverse("worktime-balance-list") - with django_assert_num_queries(2): + with django_assert_num_queries(1): result = auth_client.get(url, data={"last_reported_date": 1}) assert result.status_code == status.HTTP_200_OK @@ -215,7 +215,7 @@ def test_worktime_balance_list_last_reported_date( url = reverse("worktime-balance-list") - with django_assert_num_queries(10): + with django_assert_num_queries(9): result = auth_client.get(url, data={"last_reported_date": 1}) assert result.status_code == status.HTTP_200_OK diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index bf50c98a6..8a425a221 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -29,7 +29,7 @@ def test_project_list_include(auth_client, django_assert_num_queries, project): url = reverse("project-list") - with django_assert_num_queries(8): + with django_assert_num_queries(7): response = auth_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, @@ -41,7 +41,7 @@ def test_project_list_include(auth_client, django_assert_num_queries, project): assert json["data"][0]["id"] == str(project.id) -def test_project_detail_no_auth(client, project): +def test_project_detail_no_auth(db, client, project): url = reverse("project-detail", args=[project.id]) res = client.get(url) diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index db295de37..2bbdf63b5 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -11,7 +11,7 @@ def test_customer_statistic_list(auth_client, django_assert_num_queries): report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("customer-statistic-list") - with django_assert_num_queries(4): + with django_assert_num_queries(3): result = auth_client.get( url, data={"ordering": "duration", "include": "customer"} ) @@ -56,7 +56,7 @@ def test_customer_statistic_detail(auth_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) - with django_assert_num_queries(3): + with django_assert_num_queries(2): result = auth_client.get( url, data={"ordering": "duration", "include": "customer"} ) diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index 249fd037b..aa4df7e7f 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -11,7 +11,7 @@ def test_project_statistic_list(auth_client, django_assert_num_queries): report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("project-statistic-list") - with django_assert_num_queries(5): + with django_assert_num_queries(4): result = auth_client.get( url, data={"ordering": "duration", "include": "project,project.customer"} ) diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index 4c4098577..ee5bb286c 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -14,7 +14,7 @@ def test_task_statistic_list(auth_client, django_assert_num_queries): ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse("task-statistic-list") - with django_assert_num_queries(5): + with django_assert_num_queries(4): result = auth_client.get( url, data={ diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index c16ae6e60..a24e9c59c 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -25,7 +25,7 @@ def test_work_report_single_project(auth_client, django_assert_num_queries): ) url = reverse("work-report-list") - with django_assert_num_queries(4): + with django_assert_num_queries(3): res = auth_client.get( url, data={ @@ -60,7 +60,7 @@ def test_work_report_multiple_projects(auth_client, django_assert_num_queries): ReportFactory.create_batch(10, user=user, task=task, date=report_date) url = reverse("work-report-list") - with django_assert_num_queries(4): + with django_assert_num_queries(3): res = auth_client.get(url, data={"user": auth_client.user.id, "verified": 0}) assert res.status_code == status.HTTP_200_OK assert "20170901-WorkReports.zip" in (res["Content-Disposition"]) diff --git a/timed/settings.py b/timed/settings.py index a6d567832..7bd0ee110 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -1,4 +1,3 @@ -import datetime import os import re @@ -159,6 +158,12 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", "DEFAULT_RENDERER_CLASSES": ("rest_framework_json_api.renderers.JSONRenderer",), + "TEST_REQUEST_RENDERER_CLASSES": ( + "rest_framework_json_api.renderers.JSONRenderer", + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.MultiPartRenderer", + ), + "TEST_REQUEST_DEFAULT_FORMAT": "vnd.api+json", } JSON_API_FORMAT_FIELD_NAMES = "dasherize" @@ -167,32 +172,11 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): APPEND_SLASH = False -# Authentication definition - -AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] - -AUTH_LDAP_ENABLED = env.bool("DJANGO_AUTH_LDAP_ENABLED", default=False) -if AUTH_LDAP_ENABLED: - AUTH_LDAP_USER_ATTR_MAP = env.dict( - "DJANGO_AUTH_LDAP_USER_ATTR_MAP", - default={"first_name": "givenName", "last_name": "sn", "email": "mail"}, - ) - - AUTH_LDAP_SERVER_URI = env.str("DJANGO_AUTH_LDAP_SERVER_URI") - AUTH_LDAP_BIND_DN = env.str("DJANGO_AUTH_LDAP_BIND_DN", default="") - AUTH_LDAP_BIND_PASSWORD = env.str("DJANGO_AUTH_LDAP_BIND_PASSWORD", default="") - AUTH_LDAP_USER_DN_TEMPLATE = env.str("DJANGO_AUTH_LDAP_USER_DN_TEMPLATE") - AUTHENTICATION_BACKENDS.insert(0, "django_auth_ldap.backend.LDAPBackend") +# Authentication +# AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] AUTH_USER_MODEL = "employment.User" -SIMPLE_AUTH = { - "ACCESS_TOKEN_LIFETIME": datetime.timedelta(days=2), - "REFRESH_TOKEN_LIFETIME": datetime.timedelta(days=7), - # TODO check if this is ROTATE_REFRESH_TOKENS - # "JWT_ALLOW_REFRESH": True, -} - AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" # noqa diff --git a/timed/tests/client.py b/timed/tests/client.py deleted file mode 100644 index eedcf4837..000000000 --- a/timed/tests/client.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Helpers for testing with JSONAPI.""" - -import json - -from django.urls import reverse -from rest_framework import exceptions, status -from rest_framework.test import APIClient - - -class JSONAPIClient(APIClient): - """Base API client for testing CRUD methods with JSONAPI format.""" - - def __init__(self, *args, **kwargs): - """Initialize the API client.""" - super().__init__(*args, **kwargs) - - self._content_type = "application/vnd.api+json" - - def _parse_data(self, data): - return json.dumps(data) if data else data - - def get(self, path, data=None, **kwargs): - """Patched GET method to enforce JSONAPI format. - - :param str path: The URL to call - :param dict data: The data of the request - """ - return super().get( - path=path, data=data, content_type=self._content_type, **kwargs - ) - - def post(self, path, data=None, **kwargs): - """Patched POST method to enforce JSONAPI format. - - :param str path: The URL to call - :param dict data: The data of the request - """ - return super().post( - path=path, - data=self._parse_data(data), - content_type=self._content_type, - **kwargs, - ) - - def delete(self, path, data=None, **kwargs): - """Patched DELETE method to enforce JSONAPI format. - - :param str path: The URL to call - :param dict data: The data of the request - """ - return super().delete( - path=path, - data=self._parse_data(data), - content_type=self._content_type, - **kwargs, - ) - - def patch(self, path, data=None, **kwargs): - """Patched PATCH method to enforce JSONAPI format. - - :param str path: The URL to call - :param dict data: The data of the request - """ - return super().patch( - path=path, - data=self._parse_data(data), - content_type=self._content_type, - **kwargs, - ) - - def login(self, username, password): - """Authenticate a user. - - :param str username: Username of the user - :param str password: Password of the user - :raises: exceptions.AuthenticationFailed - """ - data = { - "data": { - "attributes": {"username": username, "password": password}, - "type": "token-obtain-pair-views", - } - } - - response = self.post(reverse("login"), data) - - if response.status_code != status.HTTP_200_OK: - raise exceptions.AuthenticationFailed() - - self.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access']}") diff --git a/timed/tests/test_client.py b/timed/tests/test_client.py deleted file mode 100644 index 21ab16f53..000000000 --- a/timed/tests/test_client.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest -from django.contrib.auth import get_user_model -from rest_framework import exceptions - -from timed.tests.client import JSONAPIClient - - -def test_client_login(db): - get_user_model().objects.create_user( - username="user", password="123qweasd", first_name="Test", last_name="User" - ) - - client = JSONAPIClient() - client.login("user", "123qweasd") - - -def test_client_login_fails(db): - client = JSONAPIClient() - with pytest.raises(exceptions.AuthenticationFailed): - client.login("someuser", "invalidpw") diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index a362cf230..2d96aedab 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -795,7 +795,7 @@ def test_report_export( url = reverse("report-export") - with django_assert_num_queries(2): + with django_assert_num_queries(1): response = auth_client.get(url, data={"file_type": file_type}) assert response.status_code == status.HTTP_200_OK diff --git a/timed/urls.py b/timed/urls.py index bc0f76d94..1dd4aabf9 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -2,12 +2,9 @@ from django.conf.urls import include, url from django.contrib import admin -from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ url(r"^admin/", admin.site.urls), - url(r"^api/v1/auth/login", TokenObtainPairView.as_view(), name="login"), - url(r"^api/v1/auth/refresh", TokenRefreshView.as_view(), name="refresh"), url(r"^api/v1/", include("timed.employment.urls")), url(r"^api/v1/", include("timed.projects.urls")), url(r"^api/v1/", include("timed.tracking.urls")), From 5d2e274b8d0026a986adaef2c49c65c365d13e92 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2020 00:14:10 +0000 Subject: [PATCH 695/980] chore(deps): bump uwsgi from 2.0.19 to 2.0.19.1 Bumps [uwsgi](https://github.com/unbit/uwsgi-docs) from 2.0.19 to 2.0.19.1. - [Release notes](https://github.com/unbit/uwsgi-docs/releases) - [Commits](https://github.com/unbit/uwsgi-docs/commits) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 78a0c649f..125dd98cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ pyexcel-ezodf==0.3.4 django-environ==0.4.5 django-money==1.1 python-redmine==2.3.0 -uwsgi==2.0.19 +uwsgi==2.0.19.1 From e59447596ea525bb3a753cbb63f5e27b93e586db Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 26 May 2020 09:40:36 +0200 Subject: [PATCH 696/980] feat(auth): implement oidc authentication Implement OIDC-based authentication using `mozilla-django-oidc`. Additionally to the default ModelBackend (used for django-admin), we subclass the Mozilla OIDC backend and customize it for our needs. That is, we accept two kinds of OIDC flows: * authorization code (using a public client) * client credentials grant (using a confidential client). The first is the accepted for the timed-frontend, the latter is used for server-to-server communication by services consuming the timed api. --- dev-config/nginx.conf | 2 +- docker-compose.override.yml | 6 +- docker-compose.yml | 17 +++- requirements-dev.txt | 1 + requirements.txt | 1 + timed/authentication.py | 103 ++++++++++++++++++++++++ timed/conftest.py | 6 ++ timed/settings.py | 52 ++++++++++++- timed/tests/test_authentication.py | 121 +++++++++++++++++++++++++++++ 9 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 timed/authentication.py create mode 100644 timed/tests/test_authentication.py diff --git a/dev-config/nginx.conf b/dev-config/nginx.conf index 21cacb433..7fe439ee6 100644 --- a/dev-config/nginx.conf +++ b/dev-config/nginx.conf @@ -14,7 +14,7 @@ server { client_max_body_size 50m; # db-flush may not be exposed in PRODUCTION! - location ~ ^/(api|admin|db-flush)/ { + location ~ ^/(api|admin|static|db-flush)/ { set $backend http://backend; proxy_pass $backend; } diff --git a/docker-compose.override.yml b/docker-compose.override.yml index aa35375e7..b9b83b64c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,4 +1,4 @@ -version: "3" +version: "3.7" services: backend: @@ -14,6 +14,8 @@ services: volumes: - ./:/app command: /bin/sh -c "wait-for-it.sh -t 60 db:5432 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" + networks: + - timed.local mailhog: image: mailhog/mailhog @@ -21,3 +23,5 @@ services: - 8025:8025 environment: - MH_UI_WEB_PATH=mailhog + networks: + - timed.local diff --git a/docker-compose.yml b/docker-compose.yml index fa8fdab1c..770fe94eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3" +version: "3.7" services: db: @@ -10,6 +10,8 @@ services: environment: - POSTGRES_USER=timed - POSTGRES_PASSWORD=timed + networks: + - timed.local frontend: image: adfinissygroup/timed-frontend:latest @@ -17,6 +19,8 @@ services: - backend ports: - 4200:80 + networks: + - timed.local backend: build: . @@ -29,6 +33,8 @@ services: - DJANGO_DATABASE_PORT=5432 - ENV=docker - STATIC_ROOT=/var/www/static + networks: + - timed.local keycloak: image: jboss/keycloak:10.0.1 @@ -44,6 +50,8 @@ services: - DB_PASSWORD=timed - PROXY_ADDRESS_FORWARDING=true command: ["-Dkeycloak.migration.action=import", "-Dkeycloak.migration.provider=singleFile", "-Dkeycloak.migration.file=/etc/keycloak/keycloak-config.json", "-b", "0.0.0.0"] + networks: + - timed.local proxy: image: nginx:1.17.10-alpine @@ -51,6 +59,13 @@ services: - 80:80 volumes: - ./dev-config/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + timed.local: + aliases: + - timed.local volumes: dbdata: + +networks: + timed.local: diff --git a/requirements-dev.txt b/requirements-dev.txt index 86728d430..c80422e8f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -21,4 +21,5 @@ pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 pytest-mock==3.1.1 pytest-randomly==3.4.0 +requests-mock==1.8.0 snapshottest==0.5.1 diff --git a/requirements.txt b/requirements.txt index 009f06aae..516f44ca8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ django-filter==2.3.0 django-multiselectfield==0.1.12 djangorestframework==3.11.0 djangorestframework-jsonapi[django-filter]==3.1.0 +mozilla-django-oidc==1.2.3 psycopg2==2.8.5 pytz==2020.1 pyexcel-webio==0.1.4 diff --git a/timed/authentication.py b/timed/authentication.py new file mode 100644 index 000000000..e0de2b606 --- /dev/null +++ b/timed/authentication.py @@ -0,0 +1,103 @@ +import base64 +import functools +import hashlib + +import requests +from django.conf import settings +from django.core.cache import cache +from django.core.exceptions import SuspiciousOperation +from django.utils.encoding import force_bytes +from mozilla_django_oidc.auth import LOGGER, OIDCAuthenticationBackend + + +class TimedOIDCAuthenticationBackend(OIDCAuthenticationBackend): + def get_introspection(self, access_token, id_token, payload): + """Return user details dictionary.""" + + basic = base64.b64encode( + f"{settings.OIDC_OP_INTROSPECT_CLIENT_ID}:{settings.OIDC_OP_INTROSPECT_CLIENT_SECRET}".encode( + "utf-8" + ) + ).decode() + headers = { + "Authorization": f"Basic {basic}", + "Content-Type": "application/x-www-form-urlencoded", + } + response = requests.post( + settings.OIDC_OP_INTROSPECT_ENDPOINT, + verify=settings.OIDC_VERIFY_SSL, + headers=headers, + data={"token": access_token}, + ) + response.raise_for_status() + return response.json() + + def get_userinfo_or_introspection(self, access_token): + try: + claims = self.cached_request( + self.get_userinfo, access_token, "auth.userinfo" + ) + except requests.HTTPError as e: + if not ( + e.response.status_code in [401, 403] and settings.OIDC_CHECK_INTROSPECT + ): + raise e + + # check introspection if userinfo fails (confidental client) + claims = self.cached_request( + self.get_introspection, access_token, "auth.introspection" + ) + if "client_id" not in claims: + raise SuspiciousOperation("client_id not present in introspection") + + return claims + + def get_or_create_user(self, access_token, id_token, payload): + """Verify claims and return user, otherwise raise an Exception.""" + + claims = self.get_userinfo_or_introspection(access_token) + + users = self.filter_users_by_claims(claims) + + if len(users) == 1: + return users[0] + elif settings.OIDC_CREATE_USER: + return self.create_user(claims) + else: + LOGGER.debug( + "Login failed: No user with username %s found, and " + "OIDC_CREATE_USER is False", + self.get_username(claims), + ) + return None + + def filter_users_by_claims(self, claims): + username = self.get_username(claims) + return self.UserModel.objects.filter(username=username) + + def cached_request(self, method, token, cache_prefix): + token_hash = hashlib.sha256(force_bytes(token)).hexdigest() + + func = functools.partial(method, token, None, None) + + return cache.get_or_set( + f"{cache_prefix}.{token_hash}", + func, + timeout=settings.OIDC_BEARER_TOKEN_REVALIDATION_TIME, + ) + + def create_user(self, claims): + """Return object for a newly created user account.""" + username = self.get_username(claims) + email = claims.get(settings.OIDC_EMAIL_CLAIM, "") + first_name = claims.get(settings.OIDC_FIRSTNAME_CLAIM, "") + last_name = claims.get(settings.OIDC_LASTNAME_CLAIM, "") + return self.UserModel.objects.create( + username=username, email=email, first_name=first_name, last_name=last_name + ) + + def get_username(self, claims): + try: + return claims[settings.OIDC_USERNAME_CLAIM] + except KeyError: + raise SuspiciousOperation("Couldn't find username claim") diff --git a/timed/conftest.py b/timed/conftest.py index ad8138892..2e9f5c9a5 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -2,6 +2,7 @@ import pytest from django.contrib.auth import get_user_model +from django.core.cache import cache from factory.base import FactoryMetaClass from pytest_factoryboy import register from rest_framework.test import APIClient @@ -90,3 +91,8 @@ def superadmin_client(superadmin_user): client.force_authenticate(user=superadmin_user) client.user = superadmin_user return client + + +@pytest.fixture(scope="function", autouse=True) +def _autoclear_cache(): + cache.clear() diff --git a/timed/settings.py b/timed/settings.py index 7bd0ee110..14aff22e3 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -60,6 +60,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "rest_framework", "django_filters", "djmoney", + "mozilla_django_oidc", "timed.employment", "timed.projects", "timed.tracking", @@ -141,6 +142,17 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): STATIC_URL = env.str("STATIC_URL", "/static/") STATIC_ROOT = env.str("STATIC_ROOT", None) +# Cache + +CACHES = { + "default": { + "BACKEND": env.str( + "CACHE_BACKEND", default="django.core.cache.backends.locmem.LocMemCache" + ), + "LOCATION": env.str("CACHE_LOCATION", ""), + } +} + # Rest framework definition REST_FRAMEWORK = { @@ -152,7 +164,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DEFAULT_PARSER_CLASSES": ("rest_framework_json_api.parsers.JSONParser",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + "mozilla_django_oidc.contrib.drf.OIDCAuthentication", ), "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", @@ -174,8 +186,11 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): # Authentication -# AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] AUTH_USER_MODEL = "employment.User" +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "timed.authentication.TimedOIDCAuthenticationBackend", +] AUTH_PASSWORD_VALIDATORS = [ { @@ -188,6 +203,39 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): }, ] +# OIDC + +OIDC_DEFAULT_BASE_URL = "http://timed.local/auth/realms/timed/protocol/openid-connect" + +OIDC_OP_USER_ENDPOINT = env.str( + "OIDC_USERINFO_ENDPOINT", default=default(f"{OIDC_DEFAULT_BASE_URL}/userinfo") +) +OIDC_OP_TOKEN_ENDPOINT = env.str( + "OIDC_TOKEN_ENDPOINT", default=default(f"{OIDC_DEFAULT_BASE_URL}/token") +) +OIDC_RP_CLIENT_ID = env.str("OIDC_CLIENT_ID", default=None) +OIDC_RP_CLIENT_SECRET = env.str("OIDC_CLIENT_SECRET", default=None) +OIDC_VERIFY_SSL = env.bool("OIDC_VERIFY_SSL", default=default(False, True)) +OIDC_CREATE_USER = env.bool("OIDC_CREATE_USER", default=False) + +OIDC_USERNAME_CLAIM = env.str("OIDC_USERNAME_CLAIM", default="preferred_username") +OIDC_EMAIL_CLAIM = env.str("OIDC_EMAIL_CLAIM", default="email") +OIDC_FIRSTNAME_CLAIM = env.str("OIDC_FIRSTNAME_CLAIM", default="given_name") +OIDC_LASTNAME_CLAIM = env.str("OIDC_LASTNAME_CLAIM", default="family_name") +# time in seconds +OIDC_BEARER_TOKEN_REVALIDATION_TIME = env.int( + "OIDC_BEARER_TOKEN_REVALIDATION_TIME", default=60 +) +OIDC_CHECK_INTROSPECT = env.bool("OIDC_CHECK_INTROSPECT", default=True) +OIDC_OP_INTROSPECT_ENDPOINT = env.str( + "OIDC_INTROSPECT_ENDPOINT", + default=default(f"{OIDC_DEFAULT_BASE_URL}/token/introspect"), +) +OIDC_OP_INTROSPECT_CLIENT_ID = env.str("OIDC_INTROSPECT_CLIENT_ID", default=None) +OIDC_OP_INTROSPECT_CLIENT_SECRET = env.str( + "OIDC_INTROSPECT_CLIENT_SECRET", default=None +) + # Email definition EMAIL_CONFIG = env.email_url("EMAIL_URL", default="smtp://localhost:25") diff --git a/timed/tests/test_authentication.py b/timed/tests/test_authentication.py new file mode 100644 index 000000000..a50fa19af --- /dev/null +++ b/timed/tests/test_authentication.py @@ -0,0 +1,121 @@ +import hashlib +import json + +import pytest +from django.contrib.auth import get_user_model +from django.core.cache import cache +from mozilla_django_oidc.contrib.drf import OIDCAuthentication +from requests.exceptions import HTTPError +from rest_framework import exceptions, status +from rest_framework.exceptions import AuthenticationFailed + + +@pytest.mark.parametrize("is_id_token", [True, False]) +@pytest.mark.parametrize( + "authentication_header,authenticated,error", + [ + ("", False, False), + ("Bearer", False, True), + ("Bearer Too many params", False, True), + ("Basic Auth", False, True), + ("Bearer Token", True, False), + ], +) +@pytest.mark.parametrize("user__username", ["1"]) +def test_authentication( + db, + user, + rf, + authentication_header, + authenticated, + error, + is_id_token, + requests_mock, + settings, +): + userinfo = {"preferred_username": "1"} + requests_mock.get(settings.OIDC_OP_USER_ENDPOINT, text=json.dumps(userinfo)) + + if not is_id_token: + userinfo = {"client_id": "test_client", "preferred_username": "1"} + requests_mock.get( + settings.OIDC_OP_USER_ENDPOINT, status_code=status.HTTP_401_UNAUTHORIZED + ) + requests_mock.post( + settings.OIDC_OP_INTROSPECT_ENDPOINT, text=json.dumps(userinfo) + ) + + request = rf.get("/openid", HTTP_AUTHORIZATION=authentication_header) + try: + result = OIDCAuthentication().authenticate(request) + except exceptions.AuthenticationFailed: + assert error + else: + if result: + key = "userinfo" if is_id_token else "introspection" + user, auth = result + assert user.is_authenticated + assert ( + cache.get(f"auth.{key}.{hashlib.sha256(b'Token').hexdigest()}") + == userinfo + ) + + +@pytest.mark.parametrize( + "create_user,username,expected_count", + [(False, "", 0), (True, "", 1), (True, "foo@example.com", 1)], +) +def test_authentication_new_user( + db, rf, requests_mock, settings, create_user, username, expected_count +): + settings.OIDC_CREATE_USER = create_user + user_model = get_user_model() + assert user_model.objects.filter(username=username).count() == 0 + + userinfo = {"preferred_username": username} + requests_mock.get(settings.OIDC_OP_USER_ENDPOINT, text=json.dumps(userinfo)) + + request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") + + try: + user, _ = OIDCAuthentication().authenticate(request) + except AuthenticationFailed: + assert not create_user + else: + assert user.username == username + + assert user_model.objects.count() == expected_count + + +def test_authentication_idp_502(db, rf, requests_mock, settings): + requests_mock.get( + settings.OIDC_OP_USER_ENDPOINT, status_code=status.HTTP_502_BAD_GATEWAY + ) + + request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") + with pytest.raises(HTTPError): + OIDCAuthentication().authenticate(request) + + +def test_authentication_idp_missing_claim(db, rf, requests_mock, settings): + settings.OIDC_USERNAME_CLAIM = "missing" + userinfo = {"preferred_username": "1"} + requests_mock.get(settings.OIDC_OP_USER_ENDPOINT, text=json.dumps(userinfo)) + + request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") + with pytest.raises(AuthenticationFailed): + OIDCAuthentication().authenticate(request) + + +def test_authentication_no_client(db, rf, requests_mock, settings): + requests_mock.get( + settings.OIDC_OP_USER_ENDPOINT, status_code=status.HTTP_401_UNAUTHORIZED + ) + requests_mock.post( + settings.OIDC_OP_INTROSPECT_ENDPOINT, + text=json.dumps({"preferred_username": "1"}), + ) + + request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") + with pytest.raises(AuthenticationFailed): + OIDCAuthentication().authenticate(request) From 98288aad3907463e0df93fc534c0c998190052a1 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 23 Jun 2020 15:01:51 +0200 Subject: [PATCH 697/980] feat(employment): add /users/me route --- timed/employment/tests/test_user.py | 27 +++++++++++++++++++++++++++ timed/employment/views.py | 9 +++++++++ 2 files changed, 36 insertions(+) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 6efaf22ce..79acce98a 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -220,3 +220,30 @@ def test_user_attributes(auth_client, project): project.reviewers.add(user) res = auth_client.get(url) assert res.json()["data"]["attributes"]["is-reviewer"] + + +def test_user_me_auth(auth_client): + """Should return the auth_client user.""" + user = auth_client.user + + url = reverse("user-me") + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + + me_data = response.json()["data"] + assert me_data["id"] == str(user.id) + + # should be the same as user-detail + url = reverse("user-detail", args=[user.id]) + + response = auth_client.get(url) + assert me_data == response.json()["data"] + + +def test_user_me_anonymous(client): + """Non-authenticated client doesn't do anything.""" + url = reverse("user-me") + + response = client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/timed/employment/views.py b/timed/employment/views.py index 7fd90d026..7c9e5ebcc 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.db.models import CharField, DateField, IntegerField, Q, Value from django.db.models.functions import Concat +from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ from rest_framework import exceptions, status from rest_framework.decorators import action @@ -54,6 +55,14 @@ def get_queryset(self): "employments", "supervisees", "supervisors" ) + @action(methods=["get"], detail=False) + def me(self, request, pk=None): + User = get_user_model() + self.object = get_object_or_404(User, pk=request.user.id) + serializer = self.get_serializer(self.object) + + return Response(serializer.data) + @action(methods=["post"], detail=True) def transfer(self, request, pk=None): """ From d3b4f58dc6a83d3b8ccc1c65df5a7577534e292b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2020 00:14:51 +0000 Subject: [PATCH 698/980] chore(deps-dev): bump coverage from 5.1 to 5.2 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.1 to 5.2. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.1...coverage-5.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c80422e8f..e1850529a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt black==19.10b0 -coverage==5.1 +coverage==5.2 factory-boy==2.12.0 flake8==3.8.3 flake8-blind-except==0.1.1 From efe00132f097abda05730299534de7676aeb791c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2020 00:14:40 +0000 Subject: [PATCH 699/980] chore(deps-dev): bump flake8-isort from 3.0.0 to 3.0.1 Bumps [flake8-isort](https://github.com/gforcada/flake8-isort) from 3.0.0 to 3.0.1. - [Release notes](https://github.com/gforcada/flake8-isort/releases) - [Changelog](https://github.com/gforcada/flake8-isort/blob/master/CHANGES.rst) - [Commits](https://github.com/gforcada/flake8-isort/compare/3.0.0...3.0.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c80422e8f..d2c2b1d50 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.2.1 flake8-deprecated==1.3 flake8-docstrings==1.5.0 -flake8-isort==3.0.0 +flake8-isort==3.0.1 flake8-string-format==0.3.0 ipdb==0.13.2 isort==4.3.21 From 9b7c55dc62f994998e53f99e418dea9ca68a181d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2020 00:17:04 +0000 Subject: [PATCH 700/980] chore(deps-dev): bump pytest-randomly from 3.4.0 to 3.4.1 Bumps [pytest-randomly](https://github.com/pytest-dev/pytest-randomly) from 3.4.0 to 3.4.1. - [Release notes](https://github.com/pytest-dev/pytest-randomly/releases) - [Changelog](https://github.com/pytest-dev/pytest-randomly/blob/master/HISTORY.rst) - [Commits](https://github.com/pytest-dev/pytest-randomly/compare/3.4.0...3.4.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c80422e8f..ca591367e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,6 +20,6 @@ pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 pytest-mock==3.1.1 -pytest-randomly==3.4.0 +pytest-randomly==3.4.1 requests-mock==1.8.0 snapshottest==0.5.1 From ee7bdb3b5d75527db1dd46e6284071e69b7e9c2f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2020 00:17:50 +0000 Subject: [PATCH 701/980] chore(deps-dev): bump pytest-mock from 3.1.1 to 3.2.0 Bumps [pytest-mock](https://github.com/pytest-dev/pytest-mock) from 3.1.1 to 3.2.0. - [Release notes](https://github.com/pytest-dev/pytest-mock/releases) - [Changelog](https://github.com/pytest-dev/pytest-mock/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.1.1...v3.2.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c80422e8f..a9baa308e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,7 +19,7 @@ pytest-django==3.9.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.1 -pytest-mock==3.1.1 +pytest-mock==3.2.0 pytest-randomly==3.4.0 requests-mock==1.8.0 snapshottest==0.5.1 From af6a9c7072c24dbe26f25cd9291320e39e9eaee0 Mon Sep 17 00:00:00 2001 From: Michael Imfeld Date: Mon, 13 Jul 2020 08:36:54 +0200 Subject: [PATCH 702/980] Docker: Support basic uwsgi config via env variables --- Dockerfile | 8 ++++++-- README.md | 4 ++++ cmd.sh | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100755 cmd.sh diff --git a/Dockerfile b/Dockerfile index f9f1285d2..a29def74d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,13 @@ ARG REQUIREMENTS=requirements.txt ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static -ENV UWSGI_INI /app/uwsgi.ini ENV WAITFORIT_TIMEOUT 0 +ENV UWSGI_INI /app/uwsgi.ini +ENV UWSGI_MAX_REQUESTS 2000 +ENV UWSGI_HARAKIRI 5 +ENV UWSGI_PROCESSES 4 + COPY requirements.txt requirements-dev.txt /app/ RUN pip install --upgrade --no-cache-dir --requirement $REQUIREMENTS --disable-pip-version-check @@ -28,4 +32,4 @@ RUN mkdir -p /var/www/static \ && ENV=docker ./manage.py collectstatic --noinput EXPOSE 80 -CMD /bin/sh -c "wait-for-it.sh $DJANGO_DATABASE_HOST:$DJANGO_DATABASE_PORT -t $WAITFORIT_TIMEOUT -- ./manage.py migrate && uwsgi" +CMD ./cmd.sh diff --git a/README.md b/README.md index 96614c7ab..78413a681 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ according to type. | `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | | `DJANGO_ADMINS` | List of people who get error notifications | not set | | `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | +| `UWSGI_INI` | Path to uwsgi.ini configuration | /app/uwsgi.ini | +| `UWSGI_MAX_REQUESTS` | uWSGI max requests | 2000 | +| `UWSGI_HARAKIRI` | uWSGI harakiri (request timeout) | 5 | +| `UWSGI_PROCESSES` | uWSGI number of processes | 4 | ## Contributing diff --git a/cmd.sh b/cmd.sh new file mode 100755 index 000000000..9eac0f786 --- /dev/null +++ b/cmd.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +sed -i 's/max-requests = .*/max-requests = '"${UWSGI_MAX_REQUESTS}"'/g' "${UWSGI_INI}" +sed -i 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' "${UWSGI_INI}" +sed -i 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" + +wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- ./manage.py migrate && uwsgi From bae7f471d3f85d3e6a80ce4713825737979d007d Mon Sep 17 00:00:00 2001 From: Michael Imfeld Date: Mon, 13 Jul 2020 10:45:44 +0200 Subject: [PATCH 703/980] Update cmd.sh Co-authored-by: Lucas Bickel --- cmd.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd.sh b/cmd.sh index 9eac0f786..4b35d8720 100755 --- a/cmd.sh +++ b/cmd.sh @@ -1,7 +1,7 @@ #!/bin/sh -sed -i 's/max-requests = .*/max-requests = '"${UWSGI_MAX_REQUESTS}"'/g' "${UWSGI_INI}" -sed -i 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' "${UWSGI_INI}" -sed -i 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" +sed -i 's/max-requests = .*/max-requests = '"${UWSGI_MAX_REQUESTS}"'/g' \ + -i 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' \ + -i 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- ./manage.py migrate && uwsgi From 720b387ef833a312cf5ed7b2f7a420fff5b0b6fb Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:54:49 +0000 Subject: [PATCH 704/980] chore(deps-dev): bump ipdb from 0.13.2 to 0.13.3 Bumps [ipdb](https://github.com/gotcha/ipdb) from 0.13.2 to 0.13.3. - [Release notes](https://github.com/gotcha/ipdb/releases) - [Changelog](https://github.com/gotcha/ipdb/blob/master/HISTORY.txt) - [Commits](https://github.com/gotcha/ipdb/compare/0.13.2...0.13.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d2c2b1d50..4b010d2dd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,7 +9,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.5.0 flake8-isort==3.0.1 flake8-string-format==0.3.0 -ipdb==0.13.2 +ipdb==0.13.3 isort==4.3.21 mockldap==0.3.0.post1 pdbpp==0.10.2 From a6aac24dbb69636b39328f0b086ecbbcf7b81856 Mon Sep 17 00:00:00 2001 From: Michael Imfeld Date: Thu, 16 Jul 2020 10:17:54 +0200 Subject: [PATCH 705/980] Fix sed in cmd.sh --- cmd.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd.sh b/cmd.sh index 4b35d8720..595e6a609 100755 --- a/cmd.sh +++ b/cmd.sh @@ -1,7 +1,8 @@ #!/bin/sh -sed -i 's/max-requests = .*/max-requests = '"${UWSGI_MAX_REQUESTS}"'/g' \ - -i 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' \ - -i 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" +sed -i \ + -e 's/max-requests = .*/max-requests = '"${UWSGI_MAX_REQUESTS}"'/g' "${UWSGI_INI}" \ + -e 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' "${UWSGI_INI}" \ + -e 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- ./manage.py migrate && uwsgi From 0679dd90030790e2de97f048b31d8cdeaff4af37 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 15 Jul 2020 13:30:51 +0200 Subject: [PATCH 706/980] chore(docs): document oidc env vars, drop unused settings --- README.md | 67 ++++++++++++++++++------------ timed/authentication.py | 2 +- timed/settings.py | 44 ++++++++++---------- timed/tests/test_authentication.py | 6 +-- 4 files changed, 67 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 78413a681..5e853472e 100644 --- a/README.md +++ b/README.md @@ -42,37 +42,52 @@ To get the application working locally for development, make sure to create a fi ENV=dev ``` +If you have existing users from the previous LDAP authentication, you want to add this line as well: + +``` +DJANGO_OIDC_USERNAME_CLAIM=preferred_username +``` + ## Configuration Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) according to type. -| Parameter | Description | Default | -| ----------------------------------- | ----------------------------------------------------- | ------------------- | -| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | -| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | -| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | -| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | -| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | -| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | -| `DJANGO_DATABASE_NAME` | Database name | timed | -| `DJANGO_DATABASE_USER` | Database username | timed | -| `DJANGO_DATABASE_HOST` | Database hostname | localhost | -| `DJANGO_DATABASE_PORT` | Database port | 5432 | -| `DJANGO_AUTH_LDAP_ENABLED` | Enable LDAP authentication | False | -| `DJANGO_AUTH_LDAP_SERVER_URI` | uri of LDAP server | not set | -| `DJANGO_AUTH_LDAP_BIND_DN` | distinguished name to use when binding to LDAP server | not set | -| `DJANGO_AUTH_LDAP_PASSWORD` | password to use with DJANGO_AUTH_LDAP_BIND_DN | not set | -| `DJANGO_AUTH_LDAP_USER_DN_TEMPLATE` | template to distinguish user’s username | not set | -| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | -| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | -| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | -| `DJANGO_ADMINS` | List of people who get error notifications | not set | -| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | -| `UWSGI_INI` | Path to uwsgi.ini configuration | /app/uwsgi.ini | -| `UWSGI_MAX_REQUESTS` | uWSGI max requests | 2000 | -| `UWSGI_HARAKIRI` | uWSGI harakiri (request timeout) | 5 | -| `UWSGI_PROCESSES` | uWSGI number of processes | 4 | +| Parameter | Description | Default | +|----------------------------------------------|-----------------------------------------------------------------------------------------|--------------------------------------------------------------| +| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | +| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | +| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | +| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | +| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | +| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | +| `DJANGO_DATABASE_NAME` | Database name | timed | +| `DJANGO_DATABASE_USER` | Database username | timed | +| `DJANGO_DATABASE_HOST` | Database hostname | localhost | +| `DJANGO_DATABASE_PORT` | Database port | 5432 | +| `DJANGO_OIDC_DEFAULT_BASE_URL` | Base URL of the OIDC provider | http://timed.local/auth/realms/timed/protocol/openid-connect | +| `DJANGO_OIDC_OP_USER_ENDPOINT` | OIDC /userinfo endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/userinfo | +| `DJANGO_OIDC_VERIFY_SSL` | Verify SSL on OIDC request | dev: False, prod: True | +| `DJANGO_OIDC_CREATE_USER` | Create new user if it doesn't exist in the database | False | +| `DJANGO_OIDC_USERNAME_CLAIM` | Username token claim for user lookup / creation | sub | +| `DJANGO_OIDC_EMAIL_CLAIM` | Email token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | email | +| `DJANGO_OIDC_FIRSTNAME_CLAIM` | First name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | given_name | +| `DJANGO_OIDC_LASTNAME_CLAIM` | Last name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | family_name | +| `DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME` | Time (in seconds) to cache a bearer token before revalidation is needed | 60 | +| `DJANGO_OIDC_CHECK_INTROSPECT` | Use token introspection for confidential clients | True | +| `DJANGO_OIDC_INTROSPECT_ENDPOINT` | OIDC token introspection endpoint (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token/introspect | +| `DJANGO_OIDC_CLIENT_ID` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | not set | +| `DJANGO_OIDC_CLIENT_SECRET` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | not set | +| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | +| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | +| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | +| `DJANGO_ADMINS` | List of people who get error notifications | not set | +| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | +| `UWSGI_INI` | Path to uwsgi.ini configuration | /app/uwsgi.ini | +| `UWSGI_MAX_REQUESTS` | uWSGI max requests | 2000 | +| `UWSGI_HARAKIRI` | uWSGI harakiri (request timeout) | 5 | +| `UWSGI_PROCESSES` | uWSGI number of processes | 4 | + ## Contributing diff --git a/timed/authentication.py b/timed/authentication.py index e0de2b606..5f535366f 100644 --- a/timed/authentication.py +++ b/timed/authentication.py @@ -15,7 +15,7 @@ def get_introspection(self, access_token, id_token, payload): """Return user details dictionary.""" basic = base64.b64encode( - f"{settings.OIDC_OP_INTROSPECT_CLIENT_ID}:{settings.OIDC_OP_INTROSPECT_CLIENT_SECRET}".encode( + f"{settings.OIDC_RP_CLIENT_ID}:{settings.OIDC_RP_CLIENT_SECRET}".encode( "utf-8" ) ).decode() diff --git a/timed/settings.py b/timed/settings.py index 14aff22e3..c00909e48 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -205,36 +205,36 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): # OIDC -OIDC_DEFAULT_BASE_URL = "http://timed.local/auth/realms/timed/protocol/openid-connect" +OIDC_DEFAULT_BASE_URL = env.str( + "DJANGO_OIDC_DEFAULT_BASE_URL", + default="http://timed.local/auth/realms/timed/protocol/openid-connect", +) + +# not needed in timed-backend +OIDC_OP_TOKEN_ENDPOINT = f"{OIDC_DEFAULT_BASE_URL}/token" OIDC_OP_USER_ENDPOINT = env.str( - "OIDC_USERINFO_ENDPOINT", default=default(f"{OIDC_DEFAULT_BASE_URL}/userinfo") -) -OIDC_OP_TOKEN_ENDPOINT = env.str( - "OIDC_TOKEN_ENDPOINT", default=default(f"{OIDC_DEFAULT_BASE_URL}/token") + "DJANGO_OIDC_USERINFO_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/userinfo" ) -OIDC_RP_CLIENT_ID = env.str("OIDC_CLIENT_ID", default=None) -OIDC_RP_CLIENT_SECRET = env.str("OIDC_CLIENT_SECRET", default=None) -OIDC_VERIFY_SSL = env.bool("OIDC_VERIFY_SSL", default=default(False, True)) -OIDC_CREATE_USER = env.bool("OIDC_CREATE_USER", default=False) - -OIDC_USERNAME_CLAIM = env.str("OIDC_USERNAME_CLAIM", default="preferred_username") -OIDC_EMAIL_CLAIM = env.str("OIDC_EMAIL_CLAIM", default="email") -OIDC_FIRSTNAME_CLAIM = env.str("OIDC_FIRSTNAME_CLAIM", default="given_name") -OIDC_LASTNAME_CLAIM = env.str("OIDC_LASTNAME_CLAIM", default="family_name") +OIDC_VERIFY_SSL = env.bool("DJANGO_OIDC_VERIFY_SSL", default=default(False, True)) +OIDC_CREATE_USER = env.bool("DJANGO_OIDC_CREATE_USER", default=False) + +OIDC_USERNAME_CLAIM = env.str("DJANGO_OIDC_USERNAME_CLAIM", default="sub") +OIDC_EMAIL_CLAIM = env.str("DJANGO_OIDC_EMAIL_CLAIM", default="email") +OIDC_FIRSTNAME_CLAIM = env.str("DJANGO_OIDC_FIRSTNAME_CLAIM", default="given_name") +OIDC_LASTNAME_CLAIM = env.str("DJANGO_OIDC_LASTNAME_CLAIM", default="family_name") # time in seconds OIDC_BEARER_TOKEN_REVALIDATION_TIME = env.int( - "OIDC_BEARER_TOKEN_REVALIDATION_TIME", default=60 + "DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME", default=60 ) -OIDC_CHECK_INTROSPECT = env.bool("OIDC_CHECK_INTROSPECT", default=True) +# for checking confidential client authentication +OIDC_CHECK_INTROSPECT = env.bool("DJANGO_OIDC_CHECK_INTROSPECT", default=True) OIDC_OP_INTROSPECT_ENDPOINT = env.str( - "OIDC_INTROSPECT_ENDPOINT", - default=default(f"{OIDC_DEFAULT_BASE_URL}/token/introspect"), -) -OIDC_OP_INTROSPECT_CLIENT_ID = env.str("OIDC_INTROSPECT_CLIENT_ID", default=None) -OIDC_OP_INTROSPECT_CLIENT_SECRET = env.str( - "OIDC_INTROSPECT_CLIENT_SECRET", default=None + "DJANGO_OIDC_INTROSPECT_ENDPOINT", + default=f"{OIDC_DEFAULT_BASE_URL}/token/introspect", ) +OIDC_RP_CLIENT_ID = env.str("DJANGO_OIDC_CLIENT_ID", default=None) +OIDC_RP_CLIENT_SECRET = env.str("DJANGO_OIDC_CLIENT_SECRET", default=None) # Email definition diff --git a/timed/tests/test_authentication.py b/timed/tests/test_authentication.py index a50fa19af..d3952ae11 100644 --- a/timed/tests/test_authentication.py +++ b/timed/tests/test_authentication.py @@ -33,11 +33,11 @@ def test_authentication( requests_mock, settings, ): - userinfo = {"preferred_username": "1"} + userinfo = {"sub": "1"} requests_mock.get(settings.OIDC_OP_USER_ENDPOINT, text=json.dumps(userinfo)) if not is_id_token: - userinfo = {"client_id": "test_client", "preferred_username": "1"} + userinfo = {"client_id": "test_client", "sub": "1"} requests_mock.get( settings.OIDC_OP_USER_ENDPOINT, status_code=status.HTTP_401_UNAUTHORIZED ) @@ -72,7 +72,7 @@ def test_authentication_new_user( user_model = get_user_model() assert user_model.objects.filter(username=username).count() == 0 - userinfo = {"preferred_username": username} + userinfo = {"sub": username} requests_mock.get(settings.OIDC_OP_USER_ENDPOINT, text=json.dumps(userinfo)) request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") From c8ddcc545fd4ddcc79b591447805c594585cce9e Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 16 Jul 2020 16:02:07 +0200 Subject: [PATCH 707/980] chore: move configs to setup.cfg, add pytest section --- .coveragerc | 24 ------------------------ .isort.cfg | 9 --------- .flake8 => setup.cfg | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 33 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .isort.cfg rename .flake8 => setup.cfg (57%) diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 4e6fce89b..000000000 --- a/.coveragerc +++ /dev/null @@ -1,24 +0,0 @@ -[run] -source=. - -[report] -fail_under=100 - -exclude_lines = - pragma: no cover - pragma: todo cover - def __str__ - def __unicode__ - def __repr__ - -omit= - */migrations/* - */apps.py - */admin.py - manage.py - timed/settings_*.py - timed/wsgi.py - timed/forms.py - setup.py - -show_missing = True diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 3a892b0b8..000000000 --- a/.isort.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[settings] -skip=migrations,snapshots -known_first_party=timed -known_third_party=pytest_factoryboy -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -combine_as_imports=True -line_length=88 diff --git a/.flake8 b/setup.cfg similarity index 57% rename from .flake8 rename to setup.cfg index 6cc391dc4..72eceaca8 100644 --- a/.flake8 +++ b/setup.cfg @@ -31,3 +31,38 @@ ignore = max-line-length = 80 exclude = migrations snapshots max-complexity = 10 + +[tool:isort] +skip=migrations,snapshots +known_first_party=timed +known_third_party=pytest_factoryboy +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +combine_as_imports=True +line_length=88 + +[tool:pytest] +addopts = -n auto --reuse-db --randomly-seed=1521188766 --randomly-dont-reorganize + +[coverage:run] +source=. + +[coverage:report] +fail_under=100 +exclude_lines = + pragma: no cover + pragma: todo cover + def __str__ + def __unicode__ + def __repr__ +omit= + */migrations/* + */apps.py + */admin.py + manage.py + timed/settings_*.py + timed/wsgi.py + timed/forms.py + setup.py +show_missing = True From f514328a1a3f7715dcfa7a5ea7811640138bea20 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 6 Aug 2020 18:13:09 +0200 Subject: [PATCH 708/980] feat(admin): allow admin login using oidc Use mozilla-django-oidc server-side authentication flow for admin. Create custom AdminConfig to use adapted login template starting the flow. --- dev-config/nginx.conf | 2 +- docker-compose.override.yml | 8 +++++ docker-compose.yml | 2 -- timed/admin.py | 5 +++ timed/apps.py | 10 ++++++ timed/authentication.py | 2 ++ timed/settings.py | 33 ++++++++++++++----- timed/templates/login.html | 63 +++++++++++++++++++++++++++++++++++++ timed/urls.py | 1 + 9 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 timed/admin.py create mode 100644 timed/apps.py create mode 100644 timed/templates/login.html diff --git a/dev-config/nginx.conf b/dev-config/nginx.conf index 7fe439ee6..f48f0245f 100644 --- a/dev-config/nginx.conf +++ b/dev-config/nginx.conf @@ -14,7 +14,7 @@ server { client_max_body_size 50m; # db-flush may not be exposed in PRODUCTION! - location ~ ^/(api|admin|static|db-flush)/ { + location ~ ^/(api|admin|oidc|static|db-flush)/ { set $backend http://backend; proxy_pass $backend; } diff --git a/docker-compose.override.yml b/docker-compose.override.yml index b9b83b64c..674d302bc 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -11,12 +11,20 @@ services: environment: - PYTHONDONTWRITEBYTECODE=1 - EMAIL_URL=smtp://mailhog:1025 + - DJANGO_OIDC_USERNAME_CLAIM=preferred_username volumes: - ./:/app command: /bin/sh -c "wait-for-it.sh -t 60 db:5432 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" networks: - timed.local + frontend: + environment: + - TIMED_SSO_CLIENT_HOST=http://timed.local/auth/realms/timed/protocol/openid-connect + - TIMED_SSO_CLIENT_ID=timed-public + networks: + - timed.local + mailhog: image: mailhog/mailhog ports: diff --git a/docker-compose.yml b/docker-compose.yml index 770fe94eb..789869491 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,6 @@ services: frontend: image: adfinissygroup/timed-frontend:latest - depends_on: - - backend ports: - 4200:80 networks: diff --git a/timed/admin.py b/timed/admin.py new file mode 100644 index 000000000..35ad7f569 --- /dev/null +++ b/timed/admin.py @@ -0,0 +1,5 @@ +from django.contrib.admin import AdminSite + + +class TimedAdminSite(AdminSite): + login_template = "login.html" diff --git a/timed/apps.py b/timed/apps.py new file mode 100644 index 000000000..88cdfd479 --- /dev/null +++ b/timed/apps.py @@ -0,0 +1,10 @@ +from django.contrib.admin.apps import AdminConfig + + +class TimedAdminConfig(AdminConfig): + """Overrides the default django.contrib.admin.site. + + This makes it possible to customize the login page. + """ + + default_site = "timed.admin.TimedAdminSite" diff --git a/timed/authentication.py b/timed/authentication.py index 5f535366f..9c053950b 100644 --- a/timed/authentication.py +++ b/timed/authentication.py @@ -88,10 +88,12 @@ def cached_request(self, method, token, cache_prefix): def create_user(self, claims): """Return object for a newly created user account.""" + username = self.get_username(claims) email = claims.get(settings.OIDC_EMAIL_CLAIM, "") first_name = claims.get(settings.OIDC_FIRSTNAME_CLAIM, "") last_name = claims.get(settings.OIDC_LASTNAME_CLAIM, "") + return self.UserModel.objects.create( username=username, email=email, first_name=first_name, last_name=last_name ) diff --git a/timed/settings.py b/timed/settings.py index c00909e48..db18dae4c 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -48,7 +48,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): INSTALLED_APPS = [ - "django.contrib.admin", + "timed.apps.TimedAdminConfig", "django.contrib.humanize", "multiselectfield", "django.forms", @@ -86,6 +86,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): # default: needed for django-admin { "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [django_root("timed", "templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -209,17 +210,26 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DJANGO_OIDC_DEFAULT_BASE_URL", default="http://timed.local/auth/realms/timed/protocol/openid-connect", ) +OIDC_OP_AUTHORIZATION_ENDPOINT = env.str( + "DJANGO_OIDC_OP_AUTHORIZATION_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/auth" +) -# not needed in timed-backend -OIDC_OP_TOKEN_ENDPOINT = f"{OIDC_DEFAULT_BASE_URL}/token" - +OIDC_OP_TOKEN_ENDPOINT = env.str( + "DJANGO_OIDC_OP_TOKEN_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/token" +) OIDC_OP_USER_ENDPOINT = env.str( "DJANGO_OIDC_USERINFO_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/userinfo" ) +OIDC_OP_JWKS_ENDPOINT = env.str( + "DJANGO_OP_JWKS_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/certs" +) OIDC_VERIFY_SSL = env.bool("DJANGO_OIDC_VERIFY_SSL", default=default(False, True)) -OIDC_CREATE_USER = env.bool("DJANGO_OIDC_CREATE_USER", default=False) +OIDC_RP_SIGN_ALGO = env.str("DJANGO_OIDC_RP_SIGN_ALGO", default="RS256") -OIDC_USERNAME_CLAIM = env.str("DJANGO_OIDC_USERNAME_CLAIM", default="sub") +OIDC_CREATE_USER = env.bool("DJANGO_OIDC_CREATE_USER", default=True) +OIDC_USERNAME_CLAIM = env.str( + "DJANGO_OIDC_USERNAME_CLAIM", default=default("preferred_username", "sub") +) OIDC_EMAIL_CLAIM = env.str("DJANGO_OIDC_EMAIL_CLAIM", default="email") OIDC_FIRSTNAME_CLAIM = env.str("DJANGO_OIDC_FIRSTNAME_CLAIM", default="given_name") OIDC_LASTNAME_CLAIM = env.str("DJANGO_OIDC_LASTNAME_CLAIM", default="family_name") @@ -227,15 +237,22 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): OIDC_BEARER_TOKEN_REVALIDATION_TIME = env.int( "DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME", default=60 ) -# for checking confidential client authentication + +# introspection endpoint for checking confidential client authentication OIDC_CHECK_INTROSPECT = env.bool("DJANGO_OIDC_CHECK_INTROSPECT", default=True) OIDC_OP_INTROSPECT_ENDPOINT = env.str( "DJANGO_OIDC_INTROSPECT_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/token/introspect", ) -OIDC_RP_CLIENT_ID = env.str("DJANGO_OIDC_CLIENT_ID", default=None) +OIDC_RP_CLIENT_ID = env.str("DJANGO_OIDC_CLIENT_ID", default="timed-public") OIDC_RP_CLIENT_SECRET = env.str("DJANGO_OIDC_CLIENT_SECRET", default=None) + +# admin page after completing server-side authentication flow +LOGIN_REDIRECT_URL = env.str( + "DJANGO_ADMIN_LOGIN_REDIRECT_URL", default=default("http://timed.local/admin/") +) + # Email definition EMAIL_CONFIG = env.email_url("EMAIL_URL", default="smtp://localhost:25") diff --git a/timed/templates/login.html b/timed/templates/login.html new file mode 100644 index 000000000..4822f67d2 --- /dev/null +++ b/timed/templates/login.html @@ -0,0 +1,63 @@ +{% extends "admin/login.html" %} + +{% load i18n %} + +{% block content %} + +{% if form.errors and not form.non_field_errors %} +

+{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +

+{% endif %} + +{% if form.non_field_errors %} +{% for error in form.non_field_errors %} +

+ {{ error }} +

+{% endfor %} +{% endif %} + +
+ +{% if user.is_authenticated %} +

+{% blocktrans trimmed %} + You are authenticated as {{ username }}, but are not authorized to + access this page. Would you like to login to a different account? +{% endblocktrans %} +

+{% endif %} + +
{% csrf_token %} +
+ {{ form.username.errors }} + {{ form.username.label_tag }} {{ form.username }} +
+
+ {{ form.password.errors }} + {{ form.password.label_tag }} {{ form.password }} + +
+ {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
+ +
+
+ +{% if user.is_authenticated %} +

Current user: {{ user.email }}

+
+ +
+{% else %} + Login with SSO +{% endif %} + +
+{% endblock %} diff --git a/timed/urls.py b/timed/urls.py index 1dd4aabf9..f7148d26d 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -10,4 +10,5 @@ url(r"^api/v1/", include("timed.tracking.urls")), url(r"^api/v1/", include("timed.reports.urls")), url(r"^api/v1/", include("timed.subscription.urls")), + url(r"^oidc/", include("mozilla_django_oidc.urls")), ] From 4220721724eb69c204567eb56ee1318702a44d35 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 7 Aug 2020 13:27:36 +0200 Subject: [PATCH 709/980] chore: cleanup leftover bits --- Dockerfile | 2 -- pytest.ini | 4 ---- requirements-dev.txt | 1 - setup.py | 26 +++++++++++++------------- timed/__init__.py | 2 +- 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index a29def74d..5e7adde34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,8 +6,6 @@ RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for && chmod +x /usr/local/bin/wait-for-it.sh RUN apt-get update && apt-get install -y --no-install-recommends \ - libldap2-dev \ - libsasl2-dev \ libpq-dev \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /app diff --git a/pytest.ini b/pytest.ini index bebf1ee3d..2cac7ec3f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,10 +1,6 @@ [pytest] DJANGO_SETTINGS_MODULE=timed.settings addopts = --reuse-db --randomly-seed=1521188767 --randomly-dont-reorganize -env = - DJANGO_AUTH_LDAP_ENABLED=True - DJANGO_AUTH_LDAP_SERVER_URI=ldap://127.0.0.1 - DJANGO_AUTH_LDAP_USER_DN_TEMPLATE=uid=%(user)s,ou=people,o=test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning diff --git a/requirements-dev.txt b/requirements-dev.txt index bc920a255..4cb2ab19f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,6 @@ flake8-isort==3.0.1 flake8-string-format==0.3.0 ipdb==0.13.3 isort==4.3.21 -mockldap==0.3.0.post1 pdbpp==0.10.2 pytest==5.4.3 pytest-cov==2.10.0 diff --git a/setup.py b/setup.py index ff61bcdcb..a26938294 100644 --- a/setup.py +++ b/setup.py @@ -34,42 +34,42 @@ def find_data(packages, extensions): setup( name="timed", version=__version__, - author="Adfinis SyGroup AG", - author_email="https://adfinis-sygroup.ch/", + author="Adfinis AG", + author_email="https://adfinis.com/", description="Timetracking software", long_description=README_TEXT, install_requires=( - "python-dateutil", - "django>=1.11", - "django-auth-ldap", + "django>=2.11", + "django-excel", + "django-environ", + "django-money", "django-filter", "django-multiselectfield", "djangorestframework", "djangorestframework-jsonapi", - "djangorestframework-jwt", - "psycopg2" "pytz", + "mozilla-django-oidc", + "psycopg2", "pyexcel-webio", "pyexcel-io", - "django-excel", "pyexcel-ods3", "pyexcel-xlsx", "pyexcel-ezodf", - "django-environ", - "django-money", + "python-dateutil", "python-redmine", + "pytz", ), keywords="timetracking", - url="https://adfinis-sygroup.ch/", + url="https://adfinis.com/", packages=find_packages(), package_data=find_data(find_packages(), ["txt"]), classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "License :: OSI Approved :: " "GNU Affero General Public License v3", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.8", ], ) diff --git a/timed/__init__.py b/timed/__init__.py index def467e07..5becc17c0 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "0.12.1" +__version__ = "1.0.0" From edf0d32ffa9f01a73bd556e3b9cbd2213f2817ce Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 7 Aug 2020 13:27:58 +0200 Subject: [PATCH 710/980] chore: update dependencies --- requirements-dev.txt | 6 +++--- requirements.txt | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4cb2ab19f..c781101e1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt black==19.10b0 -coverage==5.2 +coverage==5.2.1 factory-boy==2.12.0 flake8==3.8.3 flake8-blind-except==0.1.1 @@ -12,12 +12,12 @@ flake8-string-format==0.3.0 ipdb==0.13.3 isort==4.3.21 pdbpp==0.10.2 -pytest==5.4.3 +pytest==6.0.1 pytest-cov==2.10.0 pytest-django==3.9.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 -pytest-freezegun==0.4.1 +pytest-freezegun==0.4.2 pytest-mock==3.2.0 pytest-randomly==3.4.1 requests-mock==1.8.0 diff --git a/requirements.txt b/requirements.txt index c40e095a1..067281544 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ python-dateutil==2.8.1 -django==2.2.13 +django==2.2.15 # might remove this once we find out how the jsonapi extras_require work django-filter==2.3.0 django-multiselectfield==0.1.12 -djangorestframework==3.11.0 +djangorestframework==3.11.1 djangorestframework-jsonapi[django-filter]==3.1.0 mozilla-django-oidc==1.2.3 psycopg2==2.8.5 From 907d099cc03b666465d4215f026c8e8adb754671 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 7 Aug 2020 14:01:41 +0200 Subject: [PATCH 711/980] chore!: cleanup oidc settings, env vars, readme Use separate client settings for public and confidential clients. Because we now use the server-side auth flow as well, we need separate client settings for the confidential clients. --- README.md | 75 ++++++++++++++++++++++------------------- pytest.ini | 6 ---- setup.cfg | 8 ++++- timed/authentication.py | 2 +- timed/settings.py | 19 +++++++---- 5 files changed, 62 insertions(+), 48 deletions(-) delete mode 100644 pytest.ini diff --git a/README.md b/README.md index 5e853472e..ec8c9d0c3 100644 --- a/README.md +++ b/README.md @@ -53,40 +53,47 @@ DJANGO_OIDC_USERNAME_CLAIM=preferred_username Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) according to type. -| Parameter | Description | Default | -|----------------------------------------------|-----------------------------------------------------------------------------------------|--------------------------------------------------------------| -| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | -| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | -| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | -| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | -| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | -| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | -| `DJANGO_DATABASE_NAME` | Database name | timed | -| `DJANGO_DATABASE_USER` | Database username | timed | -| `DJANGO_DATABASE_HOST` | Database hostname | localhost | -| `DJANGO_DATABASE_PORT` | Database port | 5432 | -| `DJANGO_OIDC_DEFAULT_BASE_URL` | Base URL of the OIDC provider | http://timed.local/auth/realms/timed/protocol/openid-connect | -| `DJANGO_OIDC_OP_USER_ENDPOINT` | OIDC /userinfo endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/userinfo | -| `DJANGO_OIDC_VERIFY_SSL` | Verify SSL on OIDC request | dev: False, prod: True | -| `DJANGO_OIDC_CREATE_USER` | Create new user if it doesn't exist in the database | False | -| `DJANGO_OIDC_USERNAME_CLAIM` | Username token claim for user lookup / creation | sub | -| `DJANGO_OIDC_EMAIL_CLAIM` | Email token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | email | -| `DJANGO_OIDC_FIRSTNAME_CLAIM` | First name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | given_name | -| `DJANGO_OIDC_LASTNAME_CLAIM` | Last name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | family_name | -| `DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME` | Time (in seconds) to cache a bearer token before revalidation is needed | 60 | -| `DJANGO_OIDC_CHECK_INTROSPECT` | Use token introspection for confidential clients | True | -| `DJANGO_OIDC_INTROSPECT_ENDPOINT` | OIDC token introspection endpoint (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token/introspect | -| `DJANGO_OIDC_CLIENT_ID` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | not set | -| `DJANGO_OIDC_CLIENT_SECRET` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | not set | -| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | -| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | -| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | -| `DJANGO_ADMINS` | List of people who get error notifications | not set | -| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | -| `UWSGI_INI` | Path to uwsgi.ini configuration | /app/uwsgi.ini | -| `UWSGI_MAX_REQUESTS` | uWSGI max requests | 2000 | -| `UWSGI_HARAKIRI` | uWSGI harakiri (request timeout) | 5 | -| `UWSGI_PROCESSES` | uWSGI number of processes | 4 | +| Parameter | Description | Default | +|----------------------------------------------|------------------------------------------------------------------------------------------|--------------------------------------------------------------| +| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | +| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | +| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | +| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | +| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | +| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | +| `DJANGO_DATABASE_NAME` | Database name | timed | +| `DJANGO_DATABASE_USER` | Database username | timed | +| `DJANGO_DATABASE_HOST` | Database hostname | localhost | +| `DJANGO_DATABASE_PORT` | Database port | 5432 | +| `DJANGO_OIDC_DEFAULT_BASE_URL` | Base URL of the OIDC provider | http://timed.local/auth/realms/timed/protocol/openid-connect | +| `DJANGO_OIDC_OP_AUTHORIZATION_ENDPOINT` | OIDC /auth endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/auth | +| `DJANGO_OIDC_OP_TOKEN_ENDPOINT` | OIDC /token endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token | +| `DJANGO_OIDC_OP_USER_ENDPOINT` | OIDC /userinfo endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/userinfo | +| `DJANGO_OIDC_OP_JWKS_ENDPOINT` | OIDC /certs endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/certs | +| `DJANGO_OIDC_RP_CLIENT_ID` | Client ID by your OIDC provider | timed-public | +| `DJANGO_OIDC_RP_CLIENT_SECRET` | Client secret by your OIDC provider, should be None (flow start is handled by frontend) | not set | +| `DJANGO_OIDC_RP_SIGN_ALGO` | Algorithm the OIDC provider uses to sign ID tokens | RS256 | +| `DJANGO_OIDC_VERIFY_SSL` | Verify SSL on OIDC request | dev: False, prod: True | +| `DJANGO_OIDC_CREATE_USER` | Create new user if it doesn't exist in the database | False | +| `DJANGO_OIDC_USERNAME_CLAIM` | Username token claim for user lookup / creation | sub | +| `DJANGO_OIDC_EMAIL_CLAIM` | Email token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | email | +| `DJANGO_OIDC_FIRSTNAME_CLAIM` | First name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | given_name | +| `DJANGO_OIDC_LASTNAME_CLAIM` | Last name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | family_name | +| `DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME` | Time (in seconds) to cache a bearer token before revalidation is needed | 60 | +| `DJANGO_OIDC_CHECK_INTROSPECT` | Use token introspection for confidential clients | True | +| `DJANGO_OIDC_OP_INTROSPECT_ENDPOINT` | OIDC token introspection endpoint (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token/introspect | +| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_ID` | OIDC client id (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | timed-public | +| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_SECRET` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | not set | +| `DJANGO_OIDC_ADMIN_LOGIN_REDIRECT_URL` | URL of the django-admin, to which the user is redirected after successful admin login | dev: http://timed.local/admin/, prod: not set | +| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | +| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | +| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | +| `DJANGO_ADMINS` | List of people who get error notifications | not set | +| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | +| `UWSGI_INI` | Path to uwsgi.ini configuration | /app/uwsgi.ini | +| `UWSGI_MAX_REQUESTS` | uWSGI max requests | 2000 | +| `UWSGI_HARAKIRI` | uWSGI harakiri (request timeout) | 5 | +| `UWSGI_PROCESSES` | uWSGI number of processes | 4 | ## Contributing diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 2cac7ec3f..000000000 --- a/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE=timed.settings -addopts = --reuse-db --randomly-seed=1521188767 --randomly-dont-reorganize -filterwarnings = - error::DeprecationWarning - error::PendingDeprecationWarning diff --git a/setup.cfg b/setup.cfg index 72eceaca8..09a8f52f5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,7 +43,13 @@ combine_as_imports=True line_length=88 [tool:pytest] -addopts = -n auto --reuse-db --randomly-seed=1521188766 --randomly-dont-reorganize +DJANGO_SETTINGS_MODULE=timed.settings +addopts = --reuse-db --randomly-seed=1521188767 --randomly-dont-reorganize +env= + DJANGO_OIDC_USERNAME_CLAIM=sub +filterwarnings = + error::DeprecationWarning + error::PendingDeprecationWarning [coverage:run] source=. diff --git a/timed/authentication.py b/timed/authentication.py index 9c053950b..924ce37fc 100644 --- a/timed/authentication.py +++ b/timed/authentication.py @@ -15,7 +15,7 @@ def get_introspection(self, access_token, id_token, payload): """Return user details dictionary.""" basic = base64.b64encode( - f"{settings.OIDC_RP_CLIENT_ID}:{settings.OIDC_RP_CLIENT_SECRET}".encode( + f"{settings.OIDC_RP_INTROSPECT_CLIENT_ID}:{settings.OIDC_RP_INTROSPECT_CLIENT_SECRET}".encode( "utf-8" ) ).decode() diff --git a/timed/settings.py b/timed/settings.py index db18dae4c..900459825 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -221,8 +221,12 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DJANGO_OIDC_USERINFO_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/userinfo" ) OIDC_OP_JWKS_ENDPOINT = env.str( - "DJANGO_OP_JWKS_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/certs" + "DJANGO_OIDC_OP_JWKS_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/certs" ) + +OIDC_RP_CLIENT_ID = env.str("DJANGO_OIDC_RP_CLIENT_ID", default="timed-public") +OIDC_RP_CLIENT_SECRET = env.str("DJANGO_OIDC_RP_CLIENT_SECRET", default=None) + OIDC_VERIFY_SSL = env.bool("DJANGO_OIDC_VERIFY_SSL", default=default(False, True)) OIDC_RP_SIGN_ALGO = env.str("DJANGO_OIDC_RP_SIGN_ALGO", default="RS256") @@ -241,16 +245,19 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): # introspection endpoint for checking confidential client authentication OIDC_CHECK_INTROSPECT = env.bool("DJANGO_OIDC_CHECK_INTROSPECT", default=True) OIDC_OP_INTROSPECT_ENDPOINT = env.str( - "DJANGO_OIDC_INTROSPECT_ENDPOINT", + "DJANGO_OIDC_OP_INTROSPECT_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/token/introspect", ) -OIDC_RP_CLIENT_ID = env.str("DJANGO_OIDC_CLIENT_ID", default="timed-public") -OIDC_RP_CLIENT_SECRET = env.str("DJANGO_OIDC_CLIENT_SECRET", default=None) - +OIDC_RP_INTROSPECT_CLIENT_ID = env.str( + "DJANGO_OIDC_RP_INTROSPECT_CLIENT_ID", default="timed-confidential" +) +OIDC_RP_INTROSPECT_CLIENT_SECRET = env.str( + "DJANGO_OIDC_RP_INTROSPECT_CLIENT_SECRET", default=None +) # admin page after completing server-side authentication flow LOGIN_REDIRECT_URL = env.str( - "DJANGO_ADMIN_LOGIN_REDIRECT_URL", default=default("http://timed.local/admin/") + "DJANGO_OIDC_ADMIN_LOGIN_REDIRECT_URL", default=default("http://timed.local/admin/") ) # Email definition From 7313535520b491a1b249824aa296a7f4cd52ad9f Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 11 Aug 2020 10:56:12 +0200 Subject: [PATCH 712/980] feat: setting for toggling local login --- README.md | 1 + timed/admin.py | 12 ++++++++++++ timed/settings.py | 6 ++++++ timed/templates/login.html | 9 +++++++-- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec8c9d0c3..33fbbec84 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ according to type. | `DJANGO_OIDC_RP_INTROSPECT_CLIENT_ID` | OIDC client id (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | timed-public | | `DJANGO_OIDC_RP_INTROSPECT_CLIENT_SECRET` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | not set | | `DJANGO_OIDC_ADMIN_LOGIN_REDIRECT_URL` | URL of the django-admin, to which the user is redirected after successful admin login | dev: http://timed.local/admin/, prod: not set | +| `DJANGO_ALLOW_LOCAL_LOGIN` | Enable / Disable login with local user/password (in admin) | True | | `EMAIL_URL` | Uri of email server | smtp://localhost:25 | | `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | | `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | diff --git a/timed/admin.py b/timed/admin.py index 35ad7f569..0fedc4fc5 100644 --- a/timed/admin.py +++ b/timed/admin.py @@ -1,5 +1,17 @@ +from django.conf import settings from django.contrib.admin import AdminSite +from django.views.decorators.cache import never_cache class TimedAdminSite(AdminSite): login_template = "login.html" + + @never_cache + def login(self, request, extra_context=None): + extra = {"show_local_login": settings.ALLOW_LOCAL_LOGIN} + + if isinstance(extra_context, dict): + extra_context.update(extra) + else: + extra_context = extra + return super().login(request, extra_context) diff --git a/timed/settings.py b/timed/settings.py index 900459825..f4a6c3d4a 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -260,6 +260,12 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DJANGO_OIDC_ADMIN_LOGIN_REDIRECT_URL", default=default("http://timed.local/admin/") ) +# allow / disallow login with local user / password +ALLOW_LOCAL_LOGIN = env.bool("DJANGO_ALLOW_LOCAL_LOGIN", default=True) + +if not ALLOW_LOCAL_LOGIN: # pragma: no cover + APPLICATION_BACKENDS = ["timed.authentication.TimedOIDCAuthenticationBackend"] + # Email definition EMAIL_CONFIG = env.email_url("EMAIL_URL", default="smtp://localhost:25") diff --git a/timed/templates/login.html b/timed/templates/login.html index 4822f67d2..7f3ec2605 100644 --- a/timed/templates/login.html +++ b/timed/templates/login.html @@ -29,6 +29,7 @@

{% endif %} +{% if show_local_login %}
{% csrf_token %}
{{ form.username.errors }} @@ -49,14 +50,18 @@
+
+{% endif %} {% if user.is_authenticated %} -

Current user: {{ user.email }}

+

{% trans 'Current user:' %} {{ user.email }}

{% else %} - Login with SSO +
+ +
{% endif %} From 0b64a04d03fc40a1cd31362bce72255035a14e4d Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 11 Aug 2020 13:58:24 +0200 Subject: [PATCH 713/980] chore(release): release v1.1.0 --- CHANGELOG.md | 10 ++++++++++ timed/__init__.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..43f1066f7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# v1.1.0 (11 August 2020) + +### Feature +* implement SSO OIDC login for django admin +* django-local user/password (django-admin) login is now a toggable setting, see `DJANGO_ALLOW_LOCAL_LOGIN` + + +# v1.0.0 (30 July 2020) + +See Github releases for changelog of previous versions. diff --git a/timed/__init__.py b/timed/__init__.py index 5becc17c0..6849410aa 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" From a477fe280fefd8674364328b7515cb16d95c1add Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2020 00:14:00 +0000 Subject: [PATCH 714/980] chore(deps-dev): bump flake8-isort from 3.0.1 to 4.0.0 Bumps [flake8-isort](https://github.com/gforcada/flake8-isort) from 3.0.1 to 4.0.0. - [Release notes](https://github.com/gforcada/flake8-isort/releases) - [Changelog](https://github.com/gforcada/flake8-isort/blob/master/CHANGES.rst) - [Commits](https://github.com/gforcada/flake8-isort/compare/3.0.1...4.0.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c781101e1..e116ff812 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ flake8-blind-except==0.1.1 flake8-debugger==3.2.1 flake8-deprecated==1.3 flake8-docstrings==1.5.0 -flake8-isort==3.0.1 +flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.3 isort==4.3.21 From 7b070e7dcff35265896987e1003d61e04e2ad027 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 12 Aug 2020 13:46:22 +0200 Subject: [PATCH 715/980] chore: update settings env vars --- README.md | 2 +- timed/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33fbbec84..43af4c2f2 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ according to type. | `DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME` | Time (in seconds) to cache a bearer token before revalidation is needed | 60 | | `DJANGO_OIDC_CHECK_INTROSPECT` | Use token introspection for confidential clients | True | | `DJANGO_OIDC_OP_INTROSPECT_ENDPOINT` | OIDC token introspection endpoint (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token/introspect | -| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_ID` | OIDC client id (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | timed-public | +| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_ID` | OIDC client id (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | timed-confidential | | `DJANGO_OIDC_RP_INTROSPECT_CLIENT_SECRET` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | not set | | `DJANGO_OIDC_ADMIN_LOGIN_REDIRECT_URL` | URL of the django-admin, to which the user is redirected after successful admin login | dev: http://timed.local/admin/, prod: not set | | `DJANGO_ALLOW_LOCAL_LOGIN` | Enable / Disable login with local user/password (in admin) | True | diff --git a/timed/settings.py b/timed/settings.py index f4a6c3d4a..280355e73 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -218,7 +218,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DJANGO_OIDC_OP_TOKEN_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/token" ) OIDC_OP_USER_ENDPOINT = env.str( - "DJANGO_OIDC_USERINFO_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/userinfo" + "DJANGO_OIDC_OP_USER_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/userinfo" ) OIDC_OP_JWKS_ENDPOINT = env.str( "DJANGO_OIDC_OP_JWKS_ENDPOINT", default=f"{OIDC_DEFAULT_BASE_URL}/certs" From ca82b8a6fff522653061135d0ace53abd4a7f654 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2020 11:48:26 +0000 Subject: [PATCH 716/980] chore(deps-dev): bump isort from 4.3.21 to 5.3.2 Bumps [isort](https://github.com/timothycrosley/isort) from 4.3.21 to 5.3.2. - [Release notes](https://github.com/timothycrosley/isort/releases) - [Changelog](https://github.com/timothycrosley/isort/blob/develop/CHANGELOG.md) - [Commits](https://github.com/timothycrosley/isort/compare/4.3.21...5.3.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e116ff812..04bc5e317 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.3 -isort==4.3.21 +isort==5.3.2 pdbpp==0.10.2 pytest==6.0.1 pytest-cov==2.10.0 From b458a379ab62c8880d92f85d16f5b9fa2b240f36 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2020 00:12:36 +0000 Subject: [PATCH 717/980] chore(deps-dev): bump factory-boy from 2.12.0 to 3.0.1 Bumps [factory-boy](https://github.com/FactoryBoy/factory_boy) from 2.12.0 to 3.0.1. - [Release notes](https://github.com/FactoryBoy/factory_boy/releases) - [Changelog](https://github.com/FactoryBoy/factory_boy/blob/master/docs/changelog.rst) - [Commits](https://github.com/FactoryBoy/factory_boy/compare/2.12.0...3.0.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 04bc5e317..5e68f078a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ -r requirements.txt black==19.10b0 coverage==5.2.1 -factory-boy==2.12.0 +factory-boy==3.0.1 flake8==3.8.3 flake8-blind-except==0.1.1 flake8-debugger==3.2.1 From 3df1b25f381f4f39e2db8fd7e1909d3f88fbcf89 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2020 00:12:55 +0000 Subject: [PATCH 718/980] chore(deps-dev): bump isort from 5.3.2 to 5.4.1 Bumps [isort](https://github.com/timothycrosley/isort) from 5.3.2 to 5.4.1. - [Release notes](https://github.com/timothycrosley/isort/releases) - [Changelog](https://github.com/timothycrosley/isort/blob/develop/CHANGELOG.md) - [Commits](https://github.com/timothycrosley/isort/compare/5.3.2...5.4.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 04bc5e317..69b3e63a2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.3 -isort==5.3.2 +isort==5.4.1 pdbpp==0.10.2 pytest==6.0.1 pytest-cov==2.10.0 From 83873e3aed70c7ec88b80dc4e9da23809a4232d9 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Fri, 14 Aug 2020 14:46:46 +0200 Subject: [PATCH 719/980] fix(testdata): Update test data according to SSO This will allow the login with test users that are configured in Keycloak already. --- timed/fixtures/test_data.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json index 4a7b25905..2b48d20e6 100644 --- a/timed/fixtures/test_data.json +++ b/timed/fixtures/test_data.json @@ -122,7 +122,7 @@ "is_superuser": true, "username": "admin", "first_name": "Admin", - "email": "admin@admin.admin", + "email": "admin@example.com", "is_staff": true, "is_active": true, "date_joined": "2020-03-12T09:24:27.469Z", @@ -140,14 +140,14 @@ "password": "pbkdf2_sha256$150000$pK2Jl6xl4iYO$NvhTs+T85I5Z9RTzTm/QNXbk20iM384gst9Nj0nWWrI=", "last_login": null, "is_superuser": false, - "username": "user", - "first_name": "John", - "email": "john@john.john", + "username": "axels", + "first_name": "Axel", + "email": "axel@example.com", "is_staff": false, "is_active": true, "date_joined": "2020-03-12T09:27:21Z", "tour_done": true, - "last_name": "Doe", + "last_name": "Schöni", "groups": [], "user_permissions": [], "supervisors": [3] @@ -160,14 +160,14 @@ "password": "pbkdf2_sha256$150000$R6spIXkVyNm7$Qg2vsL0klTpgTqRwXm9bu0efHtYM8aAVYsgcXqVJsF0=", "last_login": null, "is_superuser": false, - "username": "supervisor", - "first_name": "Johnson", - "email": "johnson@johnson.johnson", + "username": "fritzm", + "first_name": "Fritz", + "email": "fritz@example.com", "is_staff": false, "is_active": true, "date_joined": "2020-03-12T09:28:55Z", "tour_done": true, - "last_name": "Doeson", + "last_name": "Muster", "groups": [], "user_permissions": [], "supervisors": [] From bd6bc71cc4866931ff4d8fda4f56b34e27a81bae Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 14 Aug 2020 15:09:17 +0200 Subject: [PATCH 720/980] fix: increase uwsgi buffer-size --- uwsgi.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/uwsgi.ini b/uwsgi.ini index 2bcefda77..f12bbe7e5 100644 --- a/uwsgi.ini +++ b/uwsgi.ini @@ -6,3 +6,4 @@ harakiri = 5 processes = 4 master = True static-map = /static/=/var/www/static +buffer-size = 32768 From 3b824e845838be8bd36436d0059fa58a463d86c2 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 14 Aug 2020 16:24:01 +0200 Subject: [PATCH 721/980] chore(release): bump version --- CHANGELOG.md | 6 ++++++ timed/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f1066f7..e4275f517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v1.1.1 (14 August 2020) + +### Fix +* increase uwsgi buffer-size for big query strings + + # v1.1.0 (11 August 2020) ### Feature diff --git a/timed/__init__.py b/timed/__init__.py index 6849410aa..a82b376d2 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" From 9acb95365d471e8d657e26928a9d6b173f420c2c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2020 00:15:12 +0000 Subject: [PATCH 722/980] chore(deps-dev): bump pytest-mock from 3.2.0 to 3.3.1 Bumps [pytest-mock](https://github.com/pytest-dev/pytest-mock) from 3.2.0 to 3.3.1. - [Release notes](https://github.com/pytest-dev/pytest-mock/releases) - [Changelog](https://github.com/pytest-dev/pytest-mock/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.2.0...v3.3.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index af1ec3e4a..57f2da0ae 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,7 @@ pytest-django==3.9.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.2 -pytest-mock==3.2.0 +pytest-mock==3.3.1 pytest-randomly==3.4.1 requests-mock==1.8.0 snapshottest==0.5.1 From 455b58689d3dddb1428fe9bd31ff1934d0e9721c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2020 00:16:21 +0000 Subject: [PATCH 723/980] chore(deps): bump psycopg2 from 2.8.5 to 2.8.6 Bumps [psycopg2](https://github.com/psycopg/psycopg2) from 2.8.5 to 2.8.6. - [Release notes](https://github.com/psycopg/psycopg2/releases) - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/commits) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 067281544..82d964fdd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-multiselectfield==0.1.12 djangorestframework==3.11.1 djangorestframework-jsonapi[django-filter]==3.1.0 mozilla-django-oidc==1.2.3 -psycopg2==2.8.5 +psycopg2==2.8.6 pytz==2020.1 pyexcel-webio==0.1.4 pyexcel-io==0.5.20 From 0e4aeccc5192bc94f13cc87df8bbd9bf10e374dd Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2020 00:17:24 +0000 Subject: [PATCH 724/980] chore(deps-dev): bump isort from 5.4.1 to 5.5.4 Bumps [isort](https://github.com/pycqa/isort) from 5.4.1 to 5.5.4. - [Release notes](https://github.com/pycqa/isort/releases) - [Changelog](https://github.com/PyCQA/isort/blob/develop/CHANGELOG.md) - [Commits](https://github.com/pycqa/isort/compare/5.4.1...5.5.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index af1ec3e4a..84179ab14 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.3 -isort==5.4.1 +isort==5.5.4 pdbpp==0.10.2 pytest==6.0.1 pytest-cov==2.10.0 From 9aeab6bdffcfea1f0904cc59195f0a0eaa9d7d0a Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 2 Oct 2020 10:46:14 +0200 Subject: [PATCH 725/980] feat: introduce code owners file Let's see how this works out. This here is more of an experiment than a production setting. If it gains traction, we'll add it to more projects in the future --- CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..7c8b8104e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,3 @@ +# Code owners for the Timed backend. We include our backend dev team here. +# Since this is a split project (backend/frontend) it's rather simple +* @adfinis-sygroup/dev-backend From b8f268ea504989935a0ce2ada93599905507fede Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2020 00:22:31 +0000 Subject: [PATCH 726/980] chore(deps-dev): bump pytest from 6.0.1 to 6.1.1 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.0.1 to 6.1.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.0.1...6.1.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index af1ec3e4a..01f9f8e59 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.3.0 ipdb==0.13.3 isort==5.4.1 pdbpp==0.10.2 -pytest==6.0.1 +pytest==6.1.1 pytest-cov==2.10.0 pytest-django==3.9.0 pytest-env==0.6.2 From ac47b2b3f25119ae532d7a44cdca6bbbe3ec8048 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2020 07:58:44 +0000 Subject: [PATCH 727/980] chore(deps): bump mozilla-django-oidc from 1.2.3 to 1.2.4 Bumps [mozilla-django-oidc](https://github.com/mozilla/mozilla-django-oidc) from 1.2.3 to 1.2.4. - [Release notes](https://github.com/mozilla/mozilla-django-oidc/releases) - [Changelog](https://github.com/mozilla/mozilla-django-oidc/blob/master/HISTORY.rst) - [Commits](https://github.com/mozilla/mozilla-django-oidc/compare/1.2.3...1.2.4) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 82d964fdd..790a01c35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-filter==2.3.0 django-multiselectfield==0.1.12 djangorestframework==3.11.1 djangorestframework-jsonapi[django-filter]==3.1.0 -mozilla-django-oidc==1.2.3 +mozilla-django-oidc==1.2.4 psycopg2==2.8.6 pytz==2020.1 pyexcel-webio==0.1.4 From 978e2c9a07aed612b8cf22a116439e55fdded0db Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2020 00:14:50 +0000 Subject: [PATCH 728/980] chore(deps-dev): bump ipdb from 0.13.3 to 0.13.4 Bumps [ipdb](https://github.com/gotcha/ipdb) from 0.13.3 to 0.13.4. - [Release notes](https://github.com/gotcha/ipdb/releases) - [Changelog](https://github.com/gotcha/ipdb/blob/master/HISTORY.txt) - [Commits](https://github.com/gotcha/ipdb/compare/0.13.3...0.13.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index bb439f5cb..50eb2d807 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,7 +9,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 -ipdb==0.13.3 +ipdb==0.13.4 isort==5.5.4 pdbpp==0.10.2 pytest==6.1.1 From 203fda2fdea42c37d4699e1b860028004e52c7ce Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2020 00:15:16 +0000 Subject: [PATCH 729/980] chore(deps-dev): bump snapshottest from 0.5.1 to 0.6.0 Bumps [snapshottest](https://github.com/syrusakbary/snapshottest) from 0.5.1 to 0.6.0. - [Release notes](https://github.com/syrusakbary/snapshottest/releases) - [Changelog](https://github.com/syrusakbary/snapshottest/blob/master/CHANGELOG.md) - [Commits](https://github.com/syrusakbary/snapshottest/compare/0.5.1...0.6.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index bb439f5cb..faadfe6a8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -21,4 +21,4 @@ pytest-freezegun==0.4.2 pytest-mock==3.3.1 pytest-randomly==3.4.1 requests-mock==1.8.0 -snapshottest==0.5.1 +snapshottest==0.6.0 From 11666cc0047f69e5df017c88d38e67e2505dad14 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2020 00:15:32 +0000 Subject: [PATCH 730/980] chore(deps-dev): bump pytest-django from 3.9.0 to 3.10.0 Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 3.9.0 to 3.10.0. - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v3.9.0...v3.10.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index bb439f5cb..20abf61df 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==5.5.4 pdbpp==0.10.2 pytest==6.1.1 pytest-cov==2.10.0 -pytest-django==3.9.0 +pytest-django==3.10.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.2 From e9a05b8b5da919d107b15f07339f0e22733a3b3b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2020 07:11:12 +0000 Subject: [PATCH 731/980] chore(deps-dev): bump pytest-cov from 2.10.0 to 2.10.1 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.10.0 to 2.10.1. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.10.0...v2.10.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8aeb6fd5e..72626c074 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.13.4 isort==5.5.4 pdbpp==0.10.2 pytest==6.1.1 -pytest-cov==2.10.0 +pytest-cov==2.10.1 pytest-django==3.10.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 From b82867848812897769e174b37fd284e8d8590dd2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2020 00:16:20 +0000 Subject: [PATCH 732/980] chore(deps): bump django-filter from 2.3.0 to 2.4.0 Bumps [django-filter](https://github.com/carltongibson/django-filter) from 2.3.0 to 2.4.0. - [Release notes](https://github.com/carltongibson/django-filter/releases) - [Changelog](https://github.com/carltongibson/django-filter/blob/master/CHANGES.rst) - [Commits](https://github.com/carltongibson/django-filter/compare/2.3.0...2.4.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 82d964fdd..0f589aed2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ python-dateutil==2.8.1 django==2.2.15 # might remove this once we find out how the jsonapi extras_require work -django-filter==2.3.0 +django-filter==2.4.0 django-multiselectfield==0.1.12 djangorestframework==3.11.1 djangorestframework-jsonapi[django-filter]==3.1.0 From 71529eb337c07de95ba8570c1350e0bf82d5ea3a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2020 00:16:44 +0000 Subject: [PATCH 733/980] chore(deps-dev): bump flake8 from 3.8.3 to 3.8.4 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.8.3 to 3.8.4. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.8.3...3.8.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8aeb6fd5e..ff2d9f1e4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==19.10b0 coverage==5.2.1 factory-boy==3.0.1 -flake8==3.8.3 +flake8==3.8.4 flake8-blind-except==0.1.1 flake8-debugger==3.2.1 flake8-deprecated==1.3 From b46e6fddbf9cfe14468a433922f9acdb9fda3f2b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2020 05:43:41 +0000 Subject: [PATCH 734/980] chore(deps-dev): bump factory-boy from 3.0.1 to 3.1.0 Bumps [factory-boy](https://github.com/FactoryBoy/factory_boy) from 3.0.1 to 3.1.0. - [Release notes](https://github.com/FactoryBoy/factory_boy/releases) - [Changelog](https://github.com/FactoryBoy/factory_boy/blob/master/docs/changelog.rst) - [Commits](https://github.com/FactoryBoy/factory_boy/compare/3.0.1...3.1.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index ff2d9f1e4..b34232e1b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ -r requirements.txt black==19.10b0 coverage==5.2.1 -factory-boy==3.0.1 +factory-boy==3.1.0 flake8==3.8.4 flake8-blind-except==0.1.1 flake8-debugger==3.2.1 From 32045261c15999906040f28639fd8f408cb81d99 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2020 00:16:07 +0000 Subject: [PATCH 735/980] chore(deps): bump pyexcel-io from 0.5.20 to 0.6.2 Bumps [pyexcel-io](https://github.com/pyexcel/pyexcel-io) from 0.5.20 to 0.6.2. - [Release notes](https://github.com/pyexcel/pyexcel-io/releases) - [Changelog](https://github.com/pyexcel/pyexcel-io/blob/dev/CHANGELOG.rst) - [Commits](https://github.com/pyexcel/pyexcel-io/compare/0.5.20...v0.6.2) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d4c7a9c51..27b50614d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ mozilla-django-oidc==1.2.4 psycopg2==2.8.6 pytz==2020.1 pyexcel-webio==0.1.4 -pyexcel-io==0.5.20 +pyexcel-io==0.6.2 django-excel==0.0.10 pyexcel-ods3==0.5.3 pyexcel-xlsx==0.5.8 From b507cf570cb28433937fdf813dc671ef57631f1e Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2020 11:37:25 +0000 Subject: [PATCH 736/980] chore(deps-dev): bump black from 19.10b0 to 20.8b1 Bumps [black](https://github.com/psf/black) from 19.10b0 to 20.8b1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/master/CHANGES.md) - [Commits](https://github.com/psf/black/commits) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 19557baf9..30d73f8c4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==19.10b0 +black==20.8b1 coverage==5.2.1 factory-boy==3.1.0 flake8==3.8.4 From ac676204c5ead49f7b5300d79a5516b8848eec6a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2020 14:17:14 +0000 Subject: [PATCH 737/980] chore(deps-dev): bump coverage from 5.2.1 to 5.3 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.2.1 to 5.3. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.2.1...coverage-5.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 30d73f8c4..00c0e6b39 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt black==20.8b1 -coverage==5.2.1 +coverage==5.3 factory-boy==3.1.0 flake8==3.8.4 flake8-blind-except==0.1.1 From 65a5519ff73f879e76fe8139416ee0e9c07e8ff6 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2020 00:15:35 +0000 Subject: [PATCH 738/980] chore(deps): bump pyexcel-ods3 from 0.5.3 to 0.6.0 Bumps [pyexcel-ods3](https://github.com/pyexcel/pyexcel-ods3) from 0.5.3 to 0.6.0. - [Release notes](https://github.com/pyexcel/pyexcel-ods3/releases) - [Changelog](https://github.com/pyexcel/pyexcel-ods3/blob/dev/CHANGELOG.rst) - [Commits](https://github.com/pyexcel/pyexcel-ods3/compare/v0.5.3...v0.6.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 27b50614d..b42a7827f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pytz==2020.1 pyexcel-webio==0.1.4 pyexcel-io==0.6.2 django-excel==0.0.10 -pyexcel-ods3==0.5.3 +pyexcel-ods3==0.6.0 pyexcel-xlsx==0.5.8 pyexcel-ezodf==0.3.4 django-environ==0.4.5 From 9e3fa7ab4955a7bdc6017ac20472e1dba6afeaa7 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2020 00:15:58 +0000 Subject: [PATCH 739/980] chore(deps-dev): bump isort from 5.5.4 to 5.6.1 Bumps [isort](https://github.com/pycqa/isort) from 5.5.4 to 5.6.1. - [Release notes](https://github.com/pycqa/isort/releases) - [Changelog](https://github.com/PyCQA/isort/blob/develop/CHANGELOG.md) - [Commits](https://github.com/pycqa/isort/compare/5.5.4...5.6.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 00c0e6b39..09139340a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.4 -isort==5.5.4 +isort==5.6.1 pdbpp==0.10.2 pytest==6.1.1 pytest-cov==2.10.1 From dab4aed5e5eb524ba4dbae5ae41748492e9ff994 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2020 00:17:18 +0000 Subject: [PATCH 740/980] chore(deps-dev): bump isort from 5.6.1 to 5.6.3 Bumps [isort](https://github.com/pycqa/isort) from 5.6.1 to 5.6.3. - [Release notes](https://github.com/pycqa/isort/releases) - [Changelog](https://github.com/PyCQA/isort/blob/develop/CHANGELOG.md) - [Commits](https://github.com/pycqa/isort/compare/5.6.1...5.6.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 09139340a..a5c4f7f2b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.4 -isort==5.6.1 +isort==5.6.3 pdbpp==0.10.2 pytest==6.1.1 pytest-cov==2.10.1 From cae25727f7b123850532cfaae8530148e18cac67 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2020 00:17:38 +0000 Subject: [PATCH 741/980] chore(deps): bump pyexcel-xlsx from 0.5.8 to 0.6.0 Bumps [pyexcel-xlsx](https://github.com/pyexcel/pyexcel-xlsx) from 0.5.8 to 0.6.0. - [Release notes](https://github.com/pyexcel/pyexcel-xlsx/releases) - [Changelog](https://github.com/pyexcel/pyexcel-xlsx/blob/dev/CHANGELOG.rst) - [Commits](https://github.com/pyexcel/pyexcel-xlsx/compare/v0.5.8...v0.6.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b42a7827f..337ae14e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyexcel-webio==0.1.4 pyexcel-io==0.6.2 django-excel==0.0.10 pyexcel-ods3==0.6.0 -pyexcel-xlsx==0.5.8 +pyexcel-xlsx==0.6.0 pyexcel-ezodf==0.3.4 django-environ==0.4.5 django-money==1.1 From b165bf85d6a8ea48e27bddeaef40749bb3a1b4a6 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2020 00:14:13 +0000 Subject: [PATCH 742/980] chore(deps): bump pyexcel-io from 0.6.2 to 0.6.3 Bumps [pyexcel-io](https://github.com/pyexcel/pyexcel-io) from 0.6.2 to 0.6.3. - [Release notes](https://github.com/pyexcel/pyexcel-io/releases) - [Changelog](https://github.com/pyexcel/pyexcel-io/blob/dev/CHANGELOG.rst) - [Commits](https://github.com/pyexcel/pyexcel-io/compare/v0.6.2...v0.6.3) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 337ae14e4..ec772aaa5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ mozilla-django-oidc==1.2.4 psycopg2==2.8.6 pytz==2020.1 pyexcel-webio==0.1.4 -pyexcel-io==0.6.2 +pyexcel-io==0.6.3 django-excel==0.0.10 pyexcel-ods3==0.6.0 pyexcel-xlsx==0.6.0 From ae615bab53d190b6118952c64b78fb36417e461f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2020 00:12:10 +0000 Subject: [PATCH 743/980] chore(deps-dev): bump isort from 5.6.3 to 5.6.4 Bumps [isort](https://github.com/pycqa/isort) from 5.6.3 to 5.6.4. - [Release notes](https://github.com/pycqa/isort/releases) - [Changelog](https://github.com/PyCQA/isort/blob/develop/CHANGELOG.md) - [Commits](https://github.com/pycqa/isort/compare/5.6.3...5.6.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index a5c4f7f2b..53f5049fc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.4 -isort==5.6.3 +isort==5.6.4 pdbpp==0.10.2 pytest==6.1.1 pytest-cov==2.10.1 From 58f6c76fa79ccb40f05c8a87daa3cb5f8cc472e6 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2020 00:21:42 +0000 Subject: [PATCH 744/980] chore(deps-dev): bump pytest-django from 3.10.0 to 4.0.0 Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 3.10.0 to 4.0.0. - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v3.10.0...v4.0.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 53f5049fc..3e564d2a5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==5.6.4 pdbpp==0.10.2 pytest==6.1.1 pytest-cov==2.10.1 -pytest-django==3.10.0 +pytest-django==4.0.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.2 From 75c383096b95868ee82d141a1437ced03cc85436 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Mon, 19 Oct 2020 09:13:53 +0200 Subject: [PATCH 745/980] chore: bump postgres version in development --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 789869491..5e46ca97c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.7" services: db: - image: postgres:9.4 + image: postgres:12.4 ports: - 5432:5432 volumes: From cf9fb4ff5ed496775a25c20abc794a7c0a52baa4 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Mon, 19 Oct 2020 09:14:27 +0200 Subject: [PATCH 746/980] chore: fix typo --- timed/employment/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 23cf584dc..a0e060f80 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -244,7 +244,7 @@ def calculate_worktime(self, start, end): 3. The expected worktime consists of following elements: * Workdays * Subtracted by holidays - * Multiplicated with the worktime per day of the employment + * Multiplied with the worktime per day of the employment 4. Determine the overtime credit duration within time frame 5. The reported worktime is the sum of the durations of all reports for this user within time frame From 571a71b6d437d19f882aae85e6729f99f5692f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=B6chli?= <33978011+tk0e@users.noreply.github.com> Date: Wed, 21 Oct 2020 15:01:10 +0200 Subject: [PATCH 747/980] Only show not verified reports Change the URL to filter only for reports that are not yet verified. --- timed/reports/templates/mail/notify_reviewers_unverified.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/reports/templates/mail/notify_reviewers_unverified.txt b/timed/reports/templates/mail/notify_reviewers_unverified.txt index 7a48df704..2cdb22328 100644 --- a/timed/reports/templates/mail/notify_reviewers_unverified.txt +++ b/timed/reports/templates/mail/notify_reviewers_unverified.txt @@ -2,4 +2,4 @@ There are unverified reports which need your attention. {{message}} -Go to <{{protocol}}://{{domain}}/analysis?fromDate={{start}}&toDate={{end}}&reviewer={{reviewer.id}}&editable=1> +Go to <{{protocol}}://{{domain}}/analysis?fromDate={{start}}&toDate={{end}}&reviewer={{reviewer.id}}&editable=1&verified=0> From e4af41baf25829ea273d40a90d570a264f1a2303 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2020 00:14:08 +0000 Subject: [PATCH 748/980] chore(deps-dev): bump pytest-django from 4.0.0 to 4.1.0 Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.0.0...v4.1.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3e564d2a5..2b8f6547b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==5.6.4 pdbpp==0.10.2 pytest==6.1.1 pytest-cov==2.10.1 -pytest-django==4.0.0 +pytest-django==4.1.0 pytest-env==0.6.2 pytest-factoryboy==2.0.3 pytest-freezegun==0.4.2 From 5d0b7e511f0df79ddbcf743251bb6242d90994f2 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Wed, 28 Oct 2020 09:56:24 +0100 Subject: [PATCH 749/980] fix(auth): fix user based permissions to use the is authenticated permission properly --- timed/employment/views.py | 6 +++--- timed/permissions.py | 20 ++++++++++++++++++-- timed/tracking/views.py | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/timed/employment/views.py b/timed/employment/views.py index 7c9e5ebcc..7e2dba3f4 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -271,7 +271,7 @@ class EmploymentViewSet(ModelViewSet): filterset_class = filters.EmploymentFilterSet permission_classes = [ # super user can add/read overtime credits - IsAuthenticated & IsSuperUser + IsSuperUser # user may only read filtered results | IsAuthenticated & IsReadOnly ] @@ -335,7 +335,7 @@ class AbsenceCreditViewSet(ModelViewSet): serializer_class = serializers.AbsenceCreditSerializer permission_classes = [ # super user can add/read absence credits - IsAuthenticated & IsSuperUser + IsSuperUser # user may only read filtered results | IsAuthenticated & IsReadOnly ] @@ -366,7 +366,7 @@ class OvertimeCreditViewSet(ModelViewSet): serializer_class = serializers.OvertimeCreditSerializer permission_classes = [ # super user can add/read overtime credits - IsAuthenticated & IsSuperUser + IsSuperUser # user may only read filtered results | IsAuthenticated & IsReadOnly ] diff --git a/timed/permissions.py b/timed/permissions.py index ef61b25a3..b806be5cb 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -76,6 +76,9 @@ class IsOwner(IsAuthenticated): """Allows access to object only to owners.""" def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + return obj.user_id == request.user.id @@ -83,6 +86,9 @@ class IsSupervisor(IsAuthenticated): """Allows access to object only to supervisors.""" def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + return request.user.supervisees.filter(id=obj.user_id).exists() @@ -90,11 +96,18 @@ class IsReviewer(IsAuthenticated): """Allows access to object only to reviewers.""" def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + if request.method not in SAFE_METHODS: return request.user.reviews.exists() + return True def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + user = request.user if isinstance(obj, projects_models.Task): @@ -103,11 +116,14 @@ def has_object_permission(self, request, view, obj): return obj.task.project.reviewers.filter(id=user.id).exists() -class IsSuperUser(BasePermission): +class IsSuperUser(IsAuthenticated): """Allows access only to superuser.""" def has_permission(self, request, view): - return request.user and request.user.is_superuser + if not super().has_permission(request, view): # pragma: no cover + return False + + return request.user.is_superuser def has_object_permission(self, request, view, obj): return self.has_permission(request, view) diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 311d6ae6a..9cf42e645 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -81,7 +81,7 @@ class ReportViewSet(ModelViewSet): # but not delete them | (IsReviewer | IsSupervisor) & IsUnverified & IsNotDelete # owner may only change its own unverified reports - | IsAuthenticated & IsOwner & IsUnverified + | IsOwner & IsUnverified # all authenticated users may read all reports | IsAuthenticated & IsReadOnly ] From c18ebd176e51d189d95f7b51f2846389180a6b7a Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 28 Oct 2020 10:43:29 +0100 Subject: [PATCH 750/980] chore(release): release v1.1.2 --- CHANGELOG.md | 6 ++++++ timed/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4275f517..044f17acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v1.1.2 (28 October 2020) + +### Fix +* fix user based permissions to use the IS_AUTHENTICATED permission properly (#654) + + # v1.1.1 (14 August 2020) ### Fix diff --git a/timed/__init__.py b/timed/__init__.py index a82b376d2..72f26f596 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.1.2" From e9b6faaeb39f722880262d79950a81cbb9502c4f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 00:14:40 +0000 Subject: [PATCH 751/980] chore(deps-dev): bump pytest from 6.1.1 to 6.1.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.1.1 to 6.1.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.1.1...6.1.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 2b8f6547b..d2afecd5b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.3.0 ipdb==0.13.4 isort==5.6.4 pdbpp==0.10.2 -pytest==6.1.1 +pytest==6.1.2 pytest-cov==2.10.1 pytest-django==4.1.0 pytest-env==0.6.2 From c30b15e4022ea210ee69c8b90e8b07ab11e0d0c9 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2020 00:15:14 +0000 Subject: [PATCH 752/980] chore(deps): bump pyexcel-io from 0.6.3 to 0.6.4 Bumps [pyexcel-io](https://github.com/pyexcel/pyexcel-io) from 0.6.3 to 0.6.4. - [Release notes](https://github.com/pyexcel/pyexcel-io/releases) - [Changelog](https://github.com/pyexcel/pyexcel-io/blob/dev/CHANGELOG.rst) - [Commits](https://github.com/pyexcel/pyexcel-io/compare/v0.6.3...v0.6.4) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ec772aaa5..a929ffc4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ mozilla-django-oidc==1.2.4 psycopg2==2.8.6 pytz==2020.1 pyexcel-webio==0.1.4 -pyexcel-io==0.6.3 +pyexcel-io==0.6.4 django-excel==0.0.10 pyexcel-ods3==0.6.0 pyexcel-xlsx==0.6.0 From 9a6e590be3e50101707272f899736f9e37b95fdd Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2020 00:14:50 +0000 Subject: [PATCH 753/980] chore(deps): bump pytz from 2020.1 to 2020.4 Bumps [pytz](https://github.com/stub42/pytz) from 2020.1 to 2020.4. - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/compare/release_2020.1...release_2020.4) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a929ffc4e..bd57aff54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ djangorestframework==3.11.1 djangorestframework-jsonapi[django-filter]==3.1.0 mozilla-django-oidc==1.2.4 psycopg2==2.8.6 -pytz==2020.1 +pytz==2020.4 pyexcel-webio==0.1.4 pyexcel-io==0.6.4 django-excel==0.0.10 From e0c31586c48750ab2aed5b55787d192a8499f0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Aur=C3=A8le=20Brothier?= Date: Mon, 23 Nov 2020 15:12:31 +0100 Subject: [PATCH 754/980] =?UTF-8?q?=F0=9F=93=9D=20readme:=20add=20steps=20?= =?UTF-8?q?to=20have=20a=20working=20development=20environment=20with=20us?= =?UTF-8?q?ers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 43af4c2f2..3e33fb12b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ To get the application working locally for development, make sure to create a fi ``` ENV=dev +DJANGO_OIDC_CREATE_USER=True ``` If you have existing users from the previous LDAP authentication, you want to add this line as well: @@ -48,6 +49,28 @@ If you have existing users from the previous LDAP authentication, you want to ad DJANGO_OIDC_USERNAME_CLAIM=preferred_username ``` +Keycloak is integrated in the containers bundle. The admin interface is available at [http://http://timed.local/auth/admin](http://http://timed.local/auth/admin) with the account `admin` and password `admin`. +You will have to match the current `admin` user of the Django application by creating an entry in Keycloak. Don't forget to go in the user's page `Credentials` to set a password. +Since the user is mapped in Keycloak, you should be able to see the _Timed_ interface for that user. + +To access the Django admin interface you will have to change the admin password in Django directly: + +```console +$ make shell +root@0a036a10f3c4:/app# python manage.py changepassword admin +Changing password for user 'admin' +Password: +Password (again): +Password changed successfully for user 'admin' +``` + +Then you'll be able to login in the Django admin interface [http://timed.local/admin/](http://timed.local/admin/). + + +### Adding a user + +If you want to add other users with different roles, add them in the Keycloak interface (as they would be coming from your LDAP directory). You will also have to correct their location in the Django admin interface as it is not correctly set for the moment. Head to [http://timed.local/admin/](http://timed.local/admin/) to after having perform 1 login with the uuser. You should see that new user in the `Employment -> Users`. Click on the user and scroll down to the `Employments` section to set a `Location`. Save the user and you should now see the _Timed_ interface correctly under that account. + ## Configuration Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) From e02b567e6d856cafbd5f8a4e8d701f571ce054b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Aur=C3=A8le=20Brothier?= Date: Mon, 23 Nov 2020 16:06:08 +0100 Subject: [PATCH 755/980] Update README.md Co-authored-by: Stefan Borer --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e33fb12b..f265504f3 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,12 @@ Then you'll be able to login in the Django admin interface [http://timed.local/a ### Adding a user -If you want to add other users with different roles, add them in the Keycloak interface (as they would be coming from your LDAP directory). You will also have to correct their location in the Django admin interface as it is not correctly set for the moment. Head to [http://timed.local/admin/](http://timed.local/admin/) to after having perform 1 login with the uuser. You should see that new user in the `Employment -> Users`. Click on the user and scroll down to the `Employments` section to set a `Location`. Save the user and you should now see the _Timed_ interface correctly under that account. +If you want to add other users with different roles, add them in the Keycloak interface (as they would be coming from your LDAP directory). +You will also have to correct their employment in the Django admin interface as it is not correctly set for the moment. +Head to [http://timed.local/admin/](http://timed.local/admin/) after having perform a first login with the user. +You should see that new user in the `Employment -> Users`. +Click on the user and scroll down to the `Employments` section to set a `Location`. +Save the user and you should now see the _Timed_ interface correctly under that account. ## Configuration From 50dcbacfb00c85811a349c50ba7d1fffe60f9de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Aur=C3=A8le=20Brothier?= Date: Mon, 23 Nov 2020 16:04:20 +0100 Subject: [PATCH 756/980] cmd: ensure no input will be asked for Django migration --- cmd.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd.sh b/cmd.sh index 595e6a609..9425113eb 100755 --- a/cmd.sh +++ b/cmd.sh @@ -5,4 +5,4 @@ sed -i \ -e 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' "${UWSGI_INI}" \ -e 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" -wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- ./manage.py migrate && uwsgi +wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- ./manage.py migrate --no-input && uwsgi From 082ef6e14a406a5d3b1a5f286007169689c0cb1b Mon Sep 17 00:00:00 2001 From: Yelinz Date: Tue, 24 Nov 2020 15:13:59 +0100 Subject: [PATCH 757/980] fix: add test data users to keycloak config --- README.md | 8 +- dev-config/keycloak-config.json | 5943 +++++++++---------------------- 2 files changed, 1711 insertions(+), 4240 deletions(-) diff --git a/README.md b/README.md index f265504f3..9b086310b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ You can visit it at [http://timed.local](http://timed.local). The API can be accessed at [http://timed.local/api/v1](http://timed.local/api/v1) and the admin interface at [http://timed.local/admin/](http://timed.local/admin/). +The Keycloak admin interface can be accessed at [http://timed.local/auth/admin](http://timed.local/auth/admin) with the account `admin` and password `admin` + ## Development To get the application working locally for development, make sure to create a file `.env` with the following content: @@ -49,9 +51,9 @@ If you have existing users from the previous LDAP authentication, you want to ad DJANGO_OIDC_USERNAME_CLAIM=preferred_username ``` -Keycloak is integrated in the containers bundle. The admin interface is available at [http://http://timed.local/auth/admin](http://http://timed.local/auth/admin) with the account `admin` and password `admin`. -You will have to match the current `admin` user of the Django application by creating an entry in Keycloak. Don't forget to go in the user's page `Credentials` to set a password. -Since the user is mapped in Keycloak, you should be able to see the _Timed_ interface for that user. +The test data includes 3 users admin, fritzm and alexs with you can log into [http://timed.local](http://timed.local) + +The username and password are identical. To access the Django admin interface you will have to change the admin password in Django directly: diff --git a/dev-config/keycloak-config.json b/dev-config/keycloak-config.json index 2ef418564..1c1e5c302 100644 --- a/dev-config/keycloak-config.json +++ b/dev-config/keycloak-config.json @@ -1,4264 +1,1733 @@ -[ - { - "id": "master", - "realm": "master", - "displayName": "Keycloak", - "displayNameHtml": "
Keycloak
", - "notBefore": 0, - "revokeRefreshToken": false, - "refreshTokenMaxReuse": 0, - "accessTokenLifespan": 60, - "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, - "ssoSessionIdleTimeoutRememberMe": 0, - "ssoSessionMaxLifespanRememberMe": 0, - "offlineSessionIdleTimeout": 2592000, - "offlineSessionMaxLifespanEnabled": false, - "offlineSessionMaxLifespan": 5184000, - "clientSessionIdleTimeout": 0, - "clientSessionMaxLifespan": 0, - "accessCodeLifespan": 60, - "accessCodeLifespanUserAction": 300, - "accessCodeLifespanLogin": 1800, - "actionTokenGeneratedByAdminLifespan": 43200, - "actionTokenGeneratedByUserLifespan": 300, - "enabled": true, - "sslRequired": "external", - "registrationAllowed": false, - "registrationEmailAsUsername": false, - "rememberMe": false, - "verifyEmail": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "bruteForceProtected": false, - "permanentLockout": false, - "maxFailureWaitSeconds": 900, - "minimumQuickLoginWaitSeconds": 60, - "waitIncrementSeconds": 60, - "quickLoginCheckMilliSeconds": 1000, - "maxDeltaTimeSeconds": 43200, - "failureFactor": 30, - "roles": { - "realm": [ - { - "id": "74803635-fcb5-4a70-9d65-4cb2a2018ada", - "name": "uma_authorization", - "description": "${role_uma_authorization}", - "composite": false, - "clientRole": false, - "containerId": "master", - "attributes": {} - }, - { - "id": "6dff9f27-d194-44ed-b864-24b8a3473502", - "name": "admin", - "description": "${role_admin}", - "composite": true, - "composites": { - "realm": ["create-realm"], - "client": { - "timed-realm": [ - "view-events", - "manage-clients", - "manage-users", - "query-users", - "query-clients", - "view-clients", - "view-authorization", - "query-groups", - "manage-events", - "create-client", - "manage-identity-providers", - "manage-authorization", - "query-realms", - "manage-realm", - "view-users", - "view-identity-providers", - "impersonation", - "view-realm" - ], - "master-realm": [ - "manage-users", - "manage-realm", - "manage-events", - "create-client", - "impersonation", - "view-authorization", - "manage-identity-providers", - "view-clients", - "view-users", - "manage-clients", - "query-users", - "view-identity-providers", - "query-groups", - "query-clients", - "manage-authorization", - "view-realm", - "view-events", - "query-realms" - ] - } - }, - "clientRole": false, - "containerId": "master", - "attributes": {} - }, - { - "id": "4cf2c12f-380d-48a7-8c7a-e006807f923e", - "name": "offline_access", - "description": "${role_offline-access}", - "composite": false, - "clientRole": false, - "containerId": "master", - "attributes": {} - }, - { - "id": "e9f08816-7ed3-4b72-9348-3cbc8534f7e7", - "name": "create-realm", - "description": "${role_create-realm}", - "composite": false, - "clientRole": false, - "containerId": "master", - "attributes": {} - } - ], - "client": { - "timed-realm": [ - { - "id": "90631cfc-bb65-4863-892b-9612dff0f1f9", - "name": "view-events", - "description": "${role_view-events}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "e61882e7-7016-4375-9af9-573c96a01833", - "name": "manage-clients", - "description": "${role_manage-clients}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "a879c80d-0d7e-48cc-a3a4-23dad366427e", - "name": "manage-users", - "description": "${role_manage-users}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "63d85552-5557-488d-ad7e-0ddb58ca7445", - "name": "manage-identity-providers", - "description": "${role_manage-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "f43e31eb-49e3-4048-b35c-d0c9ca6d74a0", - "name": "query-users", - "description": "${role_query-users}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "f2b07ca3-1b38-4bec-8dbd-e795aa83c1ff", - "name": "manage-authorization", - "description": "${role_manage-authorization}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "c09884b9-0bb5-4415-8673-dd52d064777e", - "name": "manage-realm", - "description": "${role_manage-realm}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "e807576b-3335-4245-a913-8be5119f6a0c", - "name": "query-clients", - "description": "${role_query-clients}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "b08cf7c0-b82b-44ed-944f-6e74359d183e", - "name": "query-realms", - "description": "${role_query-realms}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "4420c074-3d67-44a2-8f8c-02aef033c65d", - "name": "view-users", - "description": "${role_view-users}", - "composite": true, - "composites": { - "client": { - "timed-realm": ["query-groups", "query-users"] - } - }, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "c8d41eb7-7aa6-4d23-8828-6433e7c0bfb4", - "name": "view-clients", - "description": "${role_view-clients}", - "composite": true, - "composites": { - "client": { - "timed-realm": ["query-clients"] - } - }, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "c28a671e-41e3-4208-a318-5ccb9e425b85", - "name": "view-identity-providers", - "description": "${role_view-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "bf89d2af-6827-4895-91c5-6033106b3847", - "name": "view-authorization", - "description": "${role_view-authorization}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "7698d191-6124-449c-aa18-f181cc04269a", - "name": "query-groups", - "description": "${role_query-groups}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "15872788-923d-427d-ac30-6d89cf78c12c", - "name": "impersonation", - "description": "${role_impersonation}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "fef72afc-ae5d-4033-9771-5c24a725f6f3", - "name": "manage-events", - "description": "${role_manage-events}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "b9574134-2196-43de-a4c9-8bae4ae29b22", - "name": "create-client", - "description": "${role_create-client}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - }, - { - "id": "1c248024-cbc5-4b5b-8e31-9cb0b3e523dc", - "name": "view-realm", - "description": "${role_view-realm}", - "composite": false, - "clientRole": true, - "containerId": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "attributes": {} - } - ], - "timed-public": [], - "security-admin-console": [], - "admin-cli": [], - "account-console": [], - "broker": [ - { - "id": "0427a7fb-273b-41b5-a308-28b235520366", - "name": "read-token", - "description": "${role_read-token}", - "composite": false, - "clientRole": true, - "containerId": "ffd63e02-5832-41b5-a7ea-7d029ef6e786", - "attributes": {} - } - ], - "master-realm": [ - { - "id": "96233e00-73d9-403f-8498-bd2c001c113f", - "name": "manage-realm", - "description": "${role_manage-realm}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "3f1d13cb-27a1-4545-818b-123a0397c7e8", - "name": "manage-users", - "description": "${role_manage-users}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "44c6d85e-1b75-4550-8851-d39039b6b887", - "name": "manage-clients", - "description": "${role_manage-clients}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "888d56ba-17aa-4418-a79f-f1a8b8a0059b", - "name": "query-users", - "description": "${role_query-users}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "0bd71689-329d-4a3b-84ee-2781c2e772c6", - "name": "manage-events", - "description": "${role_manage-events}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "b10c750e-1fe7-46be-a864-a934f88b202e", - "name": "view-identity-providers", - "description": "${role_view-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "d736dad1-7094-412c-9b35-487a968c376c", - "name": "create-client", - "description": "${role_create-client}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "f5813d1e-0e10-4edc-9862-b97f88139f7e", - "name": "query-clients", - "description": "${role_query-clients}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "f0839344-a108-4df9-9e0d-d12701dd140e", - "name": "query-groups", - "description": "${role_query-groups}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "515c59e9-ccc2-4ce9-902d-43e6f966d3df", - "name": "impersonation", - "description": "${role_impersonation}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "e63e2463-979b-491b-909a-d1fe6e49914a", - "name": "view-authorization", - "description": "${role_view-authorization}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "ef155b01-eeeb-4a3d-8ec0-64fbf8237821", - "name": "manage-identity-providers", - "description": "${role_manage-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "17b53fa5-8a4c-4140-a4d2-084084df5eb1", - "name": "view-clients", - "description": "${role_view-clients}", - "composite": true, - "composites": { - "client": { - "master-realm": ["query-clients"] - } - }, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "283de348-e608-4ed5-a985-8a58c23b8314", - "name": "manage-authorization", - "description": "${role_manage-authorization}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "4ef7ffc6-a6dc-448f-b630-7ac64205bf68", - "name": "view-realm", - "description": "${role_view-realm}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "e5d91425-97fe-47f9-919d-eb4531874e47", - "name": "view-events", - "description": "${role_view-events}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "ecfbe461-2a38-4c96-b166-1b7a40a914c4", - "name": "query-realms", - "description": "${role_query-realms}", - "composite": false, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - }, - { - "id": "6f0a3afc-ae1d-472a-9963-54f58c3efe73", - "name": "view-users", - "description": "${role_view-users}", - "composite": true, - "composites": { - "client": { - "master-realm": ["query-users", "query-groups"] - } - }, - "clientRole": true, - "containerId": "63680df5-5904-43ac-bf92-da985e330ad0", - "attributes": {} - } - ], - "account": [ - { - "id": "84fe5615-f6aa-4a80-8bee-66f10a2c5957", - "name": "view-consent", - "description": "${role_view-consent}", - "composite": false, - "clientRole": true, - "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", - "attributes": {} - }, - { - "id": "a7d5481f-22d5-45fc-b36c-607f1d811349", - "name": "manage-consent", - "description": "${role_manage-consent}", - "composite": true, - "composites": { - "client": { - "account": ["view-consent"] - } - }, - "clientRole": true, - "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", - "attributes": {} - }, - { - "id": "c5321071-558d-43ba-b489-a419513d53bf", - "name": "view-profile", - "description": "${role_view-profile}", - "composite": false, - "clientRole": true, - "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", - "attributes": {} - }, - { - "id": "1754170e-d583-4dc2-8fe0-c9c3fe729080", - "name": "manage-account", - "description": "${role_manage-account}", - "composite": true, - "composites": { - "client": { - "account": ["manage-account-links"] - } - }, - "clientRole": true, - "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", - "attributes": {} - }, - { - "id": "ccc89bb6-0904-4a86-aa38-78fa658767a8", - "name": "manage-account-links", - "description": "${role_manage-account-links}", - "composite": false, - "clientRole": true, - "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", - "attributes": {} - }, - { - "id": "10e64579-317e-4ce1-9289-4fef88b29dbb", - "name": "view-applications", - "description": "${role_view-applications}", - "composite": false, - "clientRole": true, - "containerId": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", - "attributes": {} - } - ] - } - }, - "groups": [], - "defaultRoles": ["offline_access", "uma_authorization"], - "requiredCredentials": ["password"], - "otpPolicyType": "totp", - "otpPolicyAlgorithm": "HmacSHA1", - "otpPolicyInitialCounter": 0, - "otpPolicyDigits": 6, - "otpPolicyLookAheadWindow": 1, - "otpPolicyPeriod": 30, - "otpSupportedApplications": ["FreeOTP", "Google Authenticator"], - "webAuthnPolicyRpEntityName": "keycloak", - "webAuthnPolicySignatureAlgorithms": ["ES256"], - "webAuthnPolicyRpId": "", - "webAuthnPolicyAttestationConveyancePreference": "not specified", - "webAuthnPolicyAuthenticatorAttachment": "not specified", - "webAuthnPolicyRequireResidentKey": "not specified", - "webAuthnPolicyUserVerificationRequirement": "not specified", - "webAuthnPolicyCreateTimeout": 0, - "webAuthnPolicyAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyAcceptableAaguids": [], - "webAuthnPolicyPasswordlessRpEntityName": "keycloak", - "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], - "webAuthnPolicyPasswordlessRpId": "", - "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", - "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", - "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", - "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", - "webAuthnPolicyPasswordlessCreateTimeout": 0, - "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyPasswordlessAcceptableAaguids": [], - "users": [ - { - "id": "26fac932-10ae-4536-bd43-06ea07921295", - "createdTimestamp": 1590596420690, - "username": "admin", - "enabled": true, - "totp": false, - "emailVerified": false, - "credentials": [ - { - "id": "02962cb4-31fa-44fd-ab1a-822e8eab8e11", - "type": "password", - "createdDate": 1590596420870, - "secretData": "{\"value\":\"XvUDxoGE9Kaz+7bwx4yCrcXD+Qju4EkF0dExsXKxgLao5Xvef9UGWGsQCQDBbSxdOC55CCdNDIf/9ieFI3QhTA==\",\"salt\":\"r5Mb9IqEYKFatadWcUBpXg==\"}", - "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" - } - ], - "disableableCredentialTypes": [], - "requiredActions": [], - "realmRoles": ["uma_authorization", "offline_access", "admin"], - "clientRoles": { - "account": ["view-profile", "manage-account"] - }, - "notBefore": 0, - "groups": [] - } - ], - "scopeMappings": [ - { - "clientScope": "offline_access", - "roles": ["offline_access"] - } - ], - "clientScopeMappings": { - "account": [ - { - "client": "account-console", - "roles": ["manage-account"] - } - ] - }, - "clients": [ - { - "id": "0a41ec37-535f-4e3e-b00e-a7c0095911ad", - "clientId": "account", - "name": "${client_account}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/master/account/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "9d74d0fa-ad6f-4fc9-bffc-04f0d8d84ce2", - "defaultRoles": ["manage-account", "view-profile"], - "redirectUris": ["/realms/master/account/*"], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "d5401ddc-5896-45b0-b76c-2bafe2a1ef73", - "clientId": "account-console", - "name": "${client_account-console}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/master/account/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "a94c8f6d-ad11-41e8-9e47-90f5c723e054", - "redirectUris": ["/realms/master/account/*"], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "pkce.code.challenge.method": "S256" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "protocolMappers": [ - { - "id": "9537a26a-17da-49d9-a4da-600af7acf929", - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", - "consentRequired": false, - "config": {} - } - ], - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "cf3c11c4-64a1-483c-af9d-e0c5eefa21fb", - "clientId": "admin-cli", - "name": "${client_admin-cli}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "9802252b-7f2e-4b70-9cfa-1f5686ff6184", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": false, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "ffd63e02-5832-41b5-a7ea-7d029ef6e786", - "clientId": "broker", - "name": "${client_broker}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "0ec59a47-4079-4a6b-91bb-5e97d9d07071", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "63680df5-5904-43ac-bf92-da985e330ad0", - "clientId": "master-realm", - "name": "master Realm", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "0f769832-9441-4574-9e58-021292024eb0", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": true, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "9d2a3d7c-f190-4357-84fc-e6aedefe1a8e", - "clientId": "security-admin-console", - "name": "${client_security-admin-console}", - "rootUrl": "${authAdminUrl}", - "baseUrl": "/admin/master/console/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "c6a648d3-92ec-4dda-9d3e-0b68923990cf", - "redirectUris": ["/admin/master/console/*"], - "webOrigins": ["+"], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "pkce.code.challenge.method": "S256" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "protocolMappers": [ - { - "id": "04f0f942-1e8c-4da0-abbb-258ff00b901c", - "name": "locale", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "locale", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" - } - } - ], - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "6062468b-8837-47dc-ae94-7dbb6e50b38e", - "clientId": "timed-public", - "rootUrl": "http://timed.local", - "adminUrl": "http://timed.local", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "c29d9abc-e4ff-4d93-b016-36e492c0d37c", - "redirectUris": ["http://timed.local/*"], - "webOrigins": ["http://timed.local"], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "saml.assertion.signature": "false", - "saml.force.post.binding": "false", - "saml.multivalued.roles": "false", - "saml.encrypt": "false", - "saml.server.signature": "false", - "saml.server.signature.keyinfo.ext": "false", - "exclude.session.state.from.auth.response": "false", - "saml_force_name_id_format": "false", - "saml.client.signature": "false", - "tls.client.certificate.bound.access.tokens": "false", - "saml.authnstatement": "false", - "display.on.consent.screen": "false", - "saml.onetimeuse.condition": "false" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "9a1aca7a-edb4-4a89-85ab-e7399300cb55", - "clientId": "timed-realm", - "name": "timed Realm", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "4477e35b-1ad2-4176-91f2-13a5505a2c78", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": true, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - } - ], - "clientScopes": [ - { - "id": "0a0f130e-8ff0-4bde-8cd4-6096321a804f", - "name": "address", - "description": "OpenID Connect built-in scope: address", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${addressScopeConsentText}" - }, - "protocolMappers": [ - { - "id": "86e3f734-a36d-497f-9605-8ac794d8490a", - "name": "address", - "protocol": "openid-connect", - "protocolMapper": "oidc-address-mapper", - "consentRequired": false, - "config": { - "user.attribute.formatted": "formatted", - "user.attribute.country": "country", - "user.attribute.postal_code": "postal_code", - "userinfo.token.claim": "true", - "user.attribute.street": "street", - "id.token.claim": "true", - "user.attribute.region": "region", - "access.token.claim": "true", - "user.attribute.locality": "locality" - } +{ + "id" : "timed", + "realm" : "timed", + "notBefore" : 0, + "revokeRefreshToken" : false, + "refreshTokenMaxReuse" : 0, + "accessTokenLifespan" : 300, + "accessTokenLifespanForImplicitFlow" : 900, + "ssoSessionIdleTimeout" : 1800, + "ssoSessionMaxLifespan" : 36000, + "ssoSessionIdleTimeoutRememberMe" : 0, + "ssoSessionMaxLifespanRememberMe" : 0, + "offlineSessionIdleTimeout" : 2592000, + "offlineSessionMaxLifespanEnabled" : false, + "offlineSessionMaxLifespan" : 5184000, + "clientSessionIdleTimeout" : 0, + "clientSessionMaxLifespan" : 0, + "accessCodeLifespan" : 60, + "accessCodeLifespanUserAction" : 300, + "accessCodeLifespanLogin" : 1800, + "actionTokenGeneratedByAdminLifespan" : 43200, + "actionTokenGeneratedByUserLifespan" : 300, + "enabled" : true, + "sslRequired" : "external", + "registrationAllowed" : false, + "registrationEmailAsUsername" : false, + "rememberMe" : false, + "verifyEmail" : false, + "loginWithEmailAllowed" : true, + "duplicateEmailsAllowed" : false, + "resetPasswordAllowed" : false, + "editUsernameAllowed" : false, + "bruteForceProtected" : false, + "permanentLockout" : false, + "maxFailureWaitSeconds" : 900, + "minimumQuickLoginWaitSeconds" : 60, + "waitIncrementSeconds" : 60, + "quickLoginCheckMilliSeconds" : 1000, + "maxDeltaTimeSeconds" : 43200, + "failureFactor" : 30, + "roles" : { + "realm" : [ { + "id" : "9ff0d967-aa79-4bf2-8a90-7bd89f66d73c", + "name" : "offline_access", + "description" : "${role_offline-access}", + "composite" : false, + "clientRole" : false, + "containerId" : "timed", + "attributes" : { } + }, { + "id" : "d99bb30b-f4d2-48f4-a053-706e42c4f7ee", + "name" : "uma_authorization", + "description" : "${role_uma_authorization}", + "composite" : false, + "clientRole" : false, + "containerId" : "timed", + "attributes" : { } + } ], + "client" : { + "realm-management" : [ { + "id" : "57a2fe69-be5f-444f-b65e-6c7f03f8d869", + "name" : "realm-admin", + "description" : "${role_realm-admin}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "create-client", "query-realms", "manage-events", "view-authorization", "manage-authorization", "view-identity-providers", "query-groups", "view-clients", "manage-identity-providers", "view-realm", "query-clients", "query-users", "manage-clients", "view-events", "manage-users", "view-users", "impersonation", "manage-realm" ] } - ] - }, - { - "id": "3da973b0-298b-40c6-8586-8cf4a3f661ee", - "name": "email", - "description": "OpenID Connect built-in scope: email", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${emailScopeConsentText}" }, - "protocolMappers": [ - { - "id": "0ab08466-49a5-4def-af7f-6d9204907bf9", - "name": "email verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "emailVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email_verified", - "jsonType.label": "boolean" - } - }, - { - "id": "46cb3f89-fbe7-4d84-b7b5-17e7660c323b", - "name": "email", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "email", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email", - "jsonType.label": "String" - } + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "da8f521e-a54b-4870-802c-ea03dc9912b1", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "b631d90e-1240-45d7-9b80-2e56c476d682", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "d718e12e-eb6b-4876-be82-46d8404955cb", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "a0685af3-f0b8-4cca-ba68-43ac45f4d9a4", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "885d9844-528e-4ce5-a3e4-97d772c21f83", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "6df9ee52-f586-4464-b088-a7a9c1f5728f", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "f0c7d896-ea82-41f6-920d-6b3796e4c749", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "a5670fbb-438d-49b1-b27d-9ddcfe429bea", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "query-clients" ] } - ] - }, - { - "id": "211fe679-f1f0-4fb9-836b-ff37f2f916d9", - "name": "microprofile-jwt", - "description": "Microprofile - JWT built-in scope", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "false" }, - "protocolMappers": [ - { - "id": "3f1aaadc-ce8d-47f0-8b54-869858bbe0b6", - "name": "groups", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "multivalued": "true", - "user.attribute": "foo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "groups", - "jsonType.label": "String" - } - }, - { - "id": "54e16ba8-4736-4784-9f3b-bc0ce8d9f7d6", - "name": "upn", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "upn", - "jsonType.label": "String" - } + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "1b54aaa2-6b4d-4743-8967-af332be88f3c", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "67b6cc9f-8496-40d0-a20a-05a3956247dc", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "a88cfadc-008c-4be0-ab43-d41e86a477bd", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "3eceb96d-7c30-44c5-b24b-f187f2354a81", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "ee0d161a-ca6b-41c0-bc5f-649241374909", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "1f37d868-c309-4e66-9172-4f79c4717cfa", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "3bb25834-99d1-42da-a548-e1c2ac345f55", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "eec090c0-32de-4af5-a30c-6caddc24f234", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "query-users", "query-groups" ] } - ] - }, - { - "id": "496b2ed4-8b60-4fe2-8a01-a363b8786584", - "name": "offline_access", - "description": "OpenID Connect built-in scope: offline_access", - "protocol": "openid-connect", - "attributes": { - "consent.screen.text": "${offlineAccessScopeConsentText}", - "display.on.consent.screen": "true" - } - }, - { - "id": "2c07210c-eef4-46e2-9475-1ccc52de9132", - "name": "phone", - "description": "OpenID Connect built-in scope: phone", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${phoneScopeConsentText}" }, - "protocolMappers": [ - { - "id": "998d966e-b254-43b2-8d38-fb98e64ca4f9", - "name": "phone number", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "phoneNumber", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number", - "jsonType.label": "String" - } - }, - { - "id": "c3656857-d6d5-41a3-a0b9-2c5017dd03d0", - "name": "phone number verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "phoneNumberVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number_verified", - "jsonType.label": "boolean" - } + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "978bf5c9-f072-483f-a812-1ac7435c32a3", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + }, { + "id" : "7bde3002-188f-4f9e-a2ae-791594539429", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "attributes" : { } + } ], + "timed-public" : [ ], + "security-admin-console" : [ ], + "admin-cli" : [ ], + "account-console" : [ ], + "broker" : [ { + "id" : "9d18b1f6-81a4-45e8-b80d-a7f413435e35", + "name" : "read-token", + "description" : "${role_read-token}", + "composite" : false, + "clientRole" : true, + "containerId" : "1061a27d-9138-4b16-8d34-5aca38a99881", + "attributes" : { } + } ], + "account" : [ { + "id" : "2e91b70b-71d0-43a4-aff2-d511ecbe336b", + "name" : "manage-consent", + "description" : "${role_manage-consent}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "view-consent" ] } - ] - }, - { - "id": "52fd43d5-52c7-4019-aa61-49cd6649eea0", - "name": "profile", - "description": "OpenID Connect built-in scope: profile", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${profileScopeConsentText}" }, - "protocolMappers": [ - { - "id": "7f5d325f-e9e3-4f33-889d-fc60ecf7f41f", - "name": "username", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "preferred_username", - "jsonType.label": "String" - } - }, - { - "id": "e30c1352-05c2-44ba-a438-784d1968497f", - "name": "profile", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "profile", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "profile", - "jsonType.label": "String" - } - }, - { - "id": "ffc6cc89-d967-464d-9180-cdc88f358664", - "name": "birthdate", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "birthdate", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "birthdate", - "jsonType.label": "String" - } - }, - { - "id": "9d67abc2-5426-4226-a609-8d6fcf18a93d", - "name": "picture", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "picture", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "picture", - "jsonType.label": "String" - } - }, - { - "id": "3742e516-53bb-4aa1-b6d4-c41f5c40d861", - "name": "updated at", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "updatedAt", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "updated_at", - "jsonType.label": "String" - } - }, - { - "id": "c01cfc17-c1a7-4cbb-b657-492cc78143a9", - "name": "website", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "website", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "website", - "jsonType.label": "String" - } - }, - { - "id": "b91d0410-51c4-4453-98ab-62a0a3d72036", - "name": "family name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "lastName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "family_name", - "jsonType.label": "String" - } - }, - { - "id": "28d6eed7-808b-4988-8ca6-6d9e478fcb8c", - "name": "given name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "firstName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "given_name", - "jsonType.label": "String" - } - }, - { - "id": "8f04208d-10c5-460e-b9a7-885430aa667e", - "name": "zoneinfo", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "zoneinfo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "zoneinfo", - "jsonType.label": "String" - } - }, - { - "id": "64bac4fb-9d19-4627-86df-e3b2485c9da8", - "name": "gender", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "gender", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "gender", - "jsonType.label": "String" - } - }, - { - "id": "4c5e932a-036a-4873-9e1f-0bbb53c3117d", - "name": "locale", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "locale", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" - } - }, - { - "id": "80e85d07-2ee1-4b4b-82e9-b0a44b3f5a17", - "name": "nickname", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "nickname", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "nickname", - "jsonType.label": "String" - } - }, - { - "id": "37fb39a0-c61a-4a20-9895-39ac6dbe1bd7", - "name": "full name", - "protocol": "openid-connect", - "protocolMapper": "oidc-full-name-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }, - { - "id": "68f70dfc-7a17-4aa7-9a49-66d2a0116d12", - "name": "middle name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "middleName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "middle_name", - "jsonType.label": "String" - } + "clientRole" : true, + "containerId" : "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes" : { } + }, { + "id" : "d4cb3e12-deb9-4d08-998b-80d81d59e32a", + "name" : "view-consent", + "description" : "${role_view-consent}", + "composite" : false, + "clientRole" : true, + "containerId" : "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes" : { } + }, { + "id" : "ed5043f6-5896-44c8-b5d1-604f3963dde4", + "name" : "view-profile", + "description" : "${role_view-profile}", + "composite" : false, + "clientRole" : true, + "containerId" : "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes" : { } + }, { + "id" : "4f96be37-f919-49ee-82c5-5bd5b8b45216", + "name" : "view-applications", + "description" : "${role_view-applications}", + "composite" : false, + "clientRole" : true, + "containerId" : "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes" : { } + }, { + "id" : "a66d557e-5e7b-40c2-9b42-9c92c6f7994a", + "name" : "manage-account", + "description" : "${role_manage-account}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "manage-account-links" ] } - ] - }, - { - "id": "8951f244-b14a-4280-b4e9-e3d902821bf6", - "name": "role_list", - "description": "SAML role list", - "protocol": "saml", - "attributes": { - "consent.screen.text": "${samlRoleListScopeConsentText}", - "display.on.consent.screen": "true" }, - "protocolMappers": [ - { - "id": "4a3ae4eb-b255-4c7d-a3f0-09d646c63707", - "name": "role list", - "protocol": "saml", - "protocolMapper": "saml-role-list-mapper", - "consentRequired": false, - "config": { - "single": "false", - "attribute.nameformat": "Basic", - "attribute.name": "Role" - } - } - ] - }, - { - "id": "671f6484-b888-49f2-955d-65fd62ac6b62", - "name": "roles", - "description": "OpenID Connect scope for add user roles to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "true", - "consent.screen.text": "${rolesScopeConsentText}" - }, - "protocolMappers": [ - { - "id": "87589f8c-3056-4310-acb8-8e134d60f2e2", - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", - "consentRequired": false, - "config": {} - }, - { - "id": "ae4c81d0-593d-4f09-b6df-f76b0686d56d", - "name": "client roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-client-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "access.token.claim": "true", - "claim.name": "resource_access.${client_id}.roles", - "jsonType.label": "String", - "multivalued": "true" - } - }, - { - "id": "19b0c16f-d4d2-42a5-a481-dd946e49b4b7", - "name": "realm roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "access.token.claim": "true", - "claim.name": "realm_access.roles", - "jsonType.label": "String", - "multivalued": "true" - } - } - ] - }, - { - "id": "c9f8fb03-5301-47c9-b762-e25cc6cb3120", - "name": "web-origins", - "description": "OpenID Connect scope for add allowed web origins to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false", - "consent.screen.text": "" - }, - "protocolMappers": [ - { - "id": "7bd83f9a-b47b-4ddd-89f2-8c95bdf655d6", - "name": "allowed web origins", - "protocol": "openid-connect", - "protocolMapper": "oidc-allowed-origins-mapper", - "consentRequired": false, - "config": {} - } - ] + "clientRole" : true, + "containerId" : "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes" : { } + }, { + "id" : "285e9a4b-523c-4455-b59c-8d599f5c0d77", + "name" : "manage-account-links", + "description" : "${role_manage-account-links}", + "composite" : false, + "clientRole" : true, + "containerId" : "fca38486-0dca-4d9f-aebc-d219d641e179", + "attributes" : { } + } ] + } + }, + "groups" : [ ], + "defaultRoles" : [ "offline_access", "uma_authorization" ], + "requiredCredentials" : [ "password" ], + "otpPolicyType" : "totp", + "otpPolicyAlgorithm" : "HmacSHA1", + "otpPolicyInitialCounter" : 0, + "otpPolicyDigits" : 6, + "otpPolicyLookAheadWindow" : 1, + "otpPolicyPeriod" : 30, + "otpSupportedApplications" : [ "FreeOTP", "Google Authenticator" ], + "webAuthnPolicyRpEntityName" : "keycloak", + "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyRpId" : "", + "webAuthnPolicyAttestationConveyancePreference" : "not specified", + "webAuthnPolicyAuthenticatorAttachment" : "not specified", + "webAuthnPolicyRequireResidentKey" : "not specified", + "webAuthnPolicyUserVerificationRequirement" : "not specified", + "webAuthnPolicyCreateTimeout" : 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyAcceptableAaguids" : [ ], + "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyPasswordlessRpId" : "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified", + "webAuthnPolicyPasswordlessCreateTimeout" : 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], + "users" : [ { + "id" : "e809412f-1920-45f4-92e1-dfcb72d47d3c", + "createdTimestamp" : 1606226264598, + "username" : "admin", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "credentials" : [ { + "id" : "42fa428a-0ca4-4951-9a4e-cb632f8e3367", + "type" : "password", + "createdDate" : 1606226276012, + "secretData" : "{\"value\":\"tXOrES4hRfM6P6v3/l3ALU4fvd+ATxb/710U5C90xyMFKPxp3KGl8iY9WS9JG43JjmNoLq7MVyj2sKISi07sgg==\",\"salt\":\"s/muBwPqjaauAXOLhGbq6w==\"}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "offline_access", "uma_authorization", "admin" ], + "clientRoles" : { + "account" : [ "view-profile", "manage-account" ] + }, + "notBefore" : 0, + "groups" : [ ] + }, { + "id" : "f51ce893-a8a0-444c-aa4e-b09f4e8df4dc", + "createdTimestamp" : 1606226312625, + "username" : "axels", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "credentials" : [ { + "id" : "ded43644-9163-4a1f-944f-0eb925896507", + "type" : "password", + "createdDate" : 1606226316611, + "secretData" : "{\"value\":\"v2TV5f4ahxtKYZVcPdtXrC3wHIGnK5ULql2kTeZGPB6vMScHrhYCGMFO+RdP1X8cJ+0gu6G4L39Uvfy2rHvmXg==\",\"salt\":\"1fc09zAbeQuUbsz3BYXsOw==\"}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "offline_access", "uma_authorization" ], + "clientRoles" : { + "account" : [ "view-profile", "manage-account" ] + }, + "notBefore" : 0, + "groups" : [ ] + }, { + "id" : "dfabf742-0eff-4699-8d59-311d96afe7a7", + "createdTimestamp" : 1606226293084, + "username" : "fritzm", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "credentials" : [ { + "id" : "a4822286-3490-497e-942b-96afbfc52fee", + "type" : "password", + "createdDate" : 1606226300256, + "secretData" : "{\"value\":\"5jgIFyz3NkHrD7CKYzuskE7IGMZoGiZID0KFDi89BzdEAaxq/OO0vSoq27SbXFutZ+LW14F950Yn/SrV830DYA==\",\"salt\":\"UQqh7+SAI4qX08CE1BsjPQ==\"}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "offline_access", "uma_authorization" ], + "clientRoles" : { + "account" : [ "view-profile", "manage-account" ] + }, + "notBefore" : 0, + "groups" : [ ] + } ], + "scopeMappings" : [ { + "clientScope" : "offline_access", + "roles" : [ "offline_access" ] + } ], + "clientScopeMappings" : { + "account" : [ { + "client" : "account-console", + "roles" : [ "manage-account" ] + } ] + }, + "clients" : [ { + "id" : "fca38486-0dca-4d9f-aebc-d219d641e179", + "clientId" : "account", + "name" : "${client_account}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/timed/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "731474de-9346-457a-a514-888887f78683", + "defaultRoles" : [ "view-profile", "manage-account" ], + "redirectUris" : [ "/realms/timed/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "0953aa7e-fac7-43c9-aba9-83ce83d91c7c", + "clientId" : "account-console", + "name" : "${client_account-console}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/timed/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "ec538ba5-bdd1-4f84-918d-90fc5e89874c", + "redirectUris" : [ "/realms/timed/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "2cd22dff-9478-416a-99fe-b2b70d18ca72", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + } ], + "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "63a6baba-3d47-4308-af73-7c763fd31cfd", + "clientId" : "admin-cli", + "name" : "${client_admin-cli}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "97bb38b7-d47c-4d37-88c2-7fed912b1f8b", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : false, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "1061a27d-9138-4b16-8d34-5aca38a99881", + "clientId" : "broker", + "name" : "${client_broker}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "66d4120f-76dc-49fe-a958-7983be2aeee8", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "ea8482c9-4974-4761-afb0-c092d89b5a0e", + "clientId" : "realm-management", + "name" : "${client_realm-management}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "e4205e45-7122-4cb5-a3fd-c63cc58ee0a1", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "aa1b6e9c-92fe-426b-9eb0-23041d027c2d", + "clientId" : "security-admin-console", + "name" : "${client_security-admin-console}", + "rootUrl" : "${authAdminUrl}", + "baseUrl" : "/admin/timed/console/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "99ae8d54-620c-4bfb-9447-62dbbce5786b", + "redirectUris" : [ "/admin/timed/console/*" ], + "webOrigins" : [ "+" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "09f41dd1-e9f6-44eb-b847-0532ea9ea522", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" } - ], - "defaultDefaultClientScopes": [ - "role_list", - "profile", - "email", - "roles", - "web-origins" - ], - "defaultOptionalClientScopes": [ - "offline_access", - "address", - "phone", - "microprofile-jwt" - ], - "browserSecurityHeaders": { - "contentSecurityPolicyReportOnly": "", - "xContentTypeOptions": "nosniff", - "xRobotsTag": "none", - "xFrameOptions": "SAMEORIGIN", - "xXSSProtection": "1; mode=block", - "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", - "strictTransportSecurity": "max-age=31536000; includeSubDomains" + } ], + "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "30472c69-7906-44be-acbc-619d1cb7d183", + "clientId" : "timed-public", + "rootUrl" : "http://timed.local", + "adminUrl" : "http://timed.local", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "bde8e0d9-c4f8-4ab6-b1db-4724b85e8db0", + "redirectUris" : [ "http://timed.local/*", "http://localhost/*" ], + "webOrigins" : [ "*" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "saml.assertion.signature" : "false", + "saml.force.post.binding" : "false", + "saml.multivalued.roles" : "false", + "saml.encrypt" : "false", + "saml.server.signature" : "false", + "saml.server.signature.keyinfo.ext" : "false", + "exclude.session.state.from.auth.response" : "false", + "saml_force_name_id_format" : "false", + "saml.client.signature" : "false", + "tls.client.certificate.bound.access.tokens" : "false", + "saml.authnstatement" : "false", + "display.on.consent.screen" : "false", + "saml.onetimeuse.condition" : "false" }, - "smtpServer": {}, - "eventsEnabled": false, - "eventsListeners": ["jboss-logging"], - "enabledEventTypes": [], - "adminEventsEnabled": false, - "adminEventsDetailsEnabled": false, - "components": { - "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ - { - "id": "eec0195d-d0e0-4e53-b9f0-d83715023ea2", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "saml-user-attribute-mapper", - "oidc-usermodel-property-mapper", - "saml-role-list-mapper", - "oidc-usermodel-attribute-mapper", - "oidc-full-name-mapper", - "oidc-sha256-pairwise-sub-mapper", - "oidc-address-mapper", - "saml-user-property-mapper" - ] - } - }, - { - "id": "1983a94a-1db2-48c3-87b2-5c3b1cfaea46", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allow-default-scopes": ["true"] - } - }, - { - "id": "0f32e488-bdc0-4ef6-bc91-10b18614ffcb", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allow-default-scopes": ["true"] - } - }, - { - "id": "f08b67c9-d36c-47a1-9e60-c8c560df5a9c", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "oidc-usermodel-property-mapper", - "saml-user-property-mapper", - "oidc-sha256-pairwise-sub-mapper", - "saml-user-attribute-mapper", - "oidc-full-name-mapper", - "oidc-usermodel-attribute-mapper", - "saml-role-list-mapper", - "oidc-address-mapper" - ] - } - }, - { - "id": "b8be5a3b-8050-4386-9864-b5c138a5ecb7", - "name": "Trusted Hosts", - "providerId": "trusted-hosts", - "subType": "anonymous", - "subComponents": {}, - "config": { - "host-sending-registration-request-must-match": ["true"], - "client-uris-must-match": ["true"] - } - }, - { - "id": "46239c4e-1d2b-44c9-aea1-7ea217ebe0ff", - "name": "Consent Required", - "providerId": "consent-required", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "id": "88b87930-bbc0-4584-8b4e-cc95037289c3", - "name": "Max Clients Limit", - "providerId": "max-clients", - "subType": "anonymous", - "subComponents": {}, - "config": { - "max-clients": ["200"] - } - }, - { - "id": "e303e4ca-9890-4f77-b5b1-d0300dd25edd", - "name": "Full Scope Disabled", - "providerId": "scope", - "subType": "anonymous", - "subComponents": {}, - "config": {} - } - ], - "org.keycloak.keys.KeyProvider": [ - { - "id": "b21c8fa4-6a5b-47c2-b9c9-64b88fa75653", - "name": "rsa-generated", - "providerId": "rsa-generated", - "subComponents": {}, - "config": { - "privateKey": [ - "MIIEpAIBAAKCAQEA4fd0huWdL1kXFcwQVuTxyF5f74sTw2Vs69RGuJvU7UKOK1B5+SwN3j3nMVe/qj743HtY6FAho3jBTaGgpc99re4odUImIk/xCQsMDF9I6wBGTgm1h6W6sQKdKHBh9B+RlL5vW0EFEL57GAHQegUcMnMC835nD6qWj0cCSpJPACsqXsYyx+Jv3KpNL+/m0jGmxmt09kSv9vv9D3HAP4zBRTwmX7k3at6fU2sPB4TbdUQFyS2gDbSfzYa3uZHBlBL2yPYkmMLYZcVJRO+ThsG5rmHKE1SV2mYUVMjnl396cnK1x2NZVgQANgQHmZ7iLbOp+Yw3oUIrCeyluUNtTvfuvQIDAQABAoIBAQDJgc3Fej/I+G6wvnCXvMSshRSSXnj6V5lhWMTUXgrspdx4XeTXwmR/mr5v7yt5m3x7yfeH++VzjPz8yLSlCLqv/2DO6HVvRdDR2qsc4V/6SR1o/BmI5M7uiUEyzb1cYUaG2agePYZR3zuQNhX+qk3x40RvdXpcqyhmjtFJRN30a9z2ljzGjNWJdCjI8k7tHWM9OHaxfHb+FyrxQxACQincVei8DHoMfO2SNW93pTJ2EBUnmEnV62G+9wuIQFZjUWVwgpJf96m+WV5OHfSPb6mBc/3HsgSINOTsZcdQXb5HJkfTF4w1DJso/ihsHgWqaJYzAN8kgbCkScAUT4oZJSxVAoGBAPNdLnB7I2HujpE8JoXizA8I4HjpkDcU8UZ+F62NdxGsCjClkCC6GTxeEDEz7kW9Tig7dTk/CwpuTmE+apM04Dp0JQZLsPeMCa8xkIm5At8yq67yi0+ZB6nIdDhovGrwCo6vBxBw4VTrbxJYrnZ3tQIpIwC4N/m4i6ob922Q9XEDAoGBAO2zBrYY28n+hLZYUb9yo5zEMG4k/Ab7qEhQFbZaNKVH0XpwPK1Ul2JOHjjgNrz8t1buBAR3wo6nxuFrbr6I3WmDQLU7YKNWX/L/rjSdifhLmZWm3oSgP9mCczdtkkPj0rV58sh0498m4pbChUvTFnx8USX2DNZ05/GdJkzOjrU/AoGAVtGjQ5VqZgGI8t8WjyT9z09HZVtNi5j5CkDpiYyyMafCauBlroc1gYe9FxCDrHWAcHHlu+p1sd7wL1jpBGMUq0XL/5b5JxbaTZnNCpTqJV4aSWtVr6vURAmzDHyw2yWPXp+qUX8zo+vp0A27D6Bc/sxWJGeT8I6ZpLIdbwULyqkCgYAkyNW7DHHG+qpTBavw8q67LelIwlR2SC+ssSgLBj6rbUfPqNrbAAJFZk1rA9e0u28r9r2Ma3QiW3h9ngCPX+LT10oGQeAcpttGYab14YNed2SXMjGxWJNI99UYuM4vz2vmRa76sowpFn1uU0AJkesi7KIqO7+U2JakX2tz62tORQKBgQDKxiB93oL3ZTKAWbm05VAjBehdnZ7YNrlxEtYEqiY4ycPnyLzG9Z63K7XgSxNWk2suUwWBHqA6GPVCRgFHzBs7hJ5oqE5SPk57Ravf/jK5YE8PBO5M4mzHalve3qk3Ze8tVVVSbnZYL/0DM/6WXcSMYCbeAvElHvgmC3EDF2w73A==" - ], - "certificate": [ - "MIICmzCCAYMCBgFyVuz5HDANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjAwNTI3MTYxNjEwWhcNMzAwNTI3MTYxNzUwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDh93SG5Z0vWRcVzBBW5PHIXl/vixPDZWzr1Ea4m9TtQo4rUHn5LA3ePecxV7+qPvjce1joUCGjeMFNoaClz32t7ih1QiYiT/EJCwwMX0jrAEZOCbWHpbqxAp0ocGH0H5GUvm9bQQUQvnsYAdB6BRwycwLzfmcPqpaPRwJKkk8AKypexjLH4m/cqk0v7+bSMabGa3T2RK/2+/0PccA/jMFFPCZfuTdq3p9Taw8HhNt1RAXJLaANtJ/Nhre5kcGUEvbI9iSYwthlxUlE75OGwbmuYcoTVJXaZhRUyOeXf3pycrXHY1lWBAA2BAeZnuIts6n5jDehQisJ7KW5Q21O9+69AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHyHAvPOBLU0pZuJ1IVpkdLMMRqbBh0ZFD4rMy9Jdb5TVIScRfYh9pyqaA+2g6zRr5AZmitADou+OUj/7MUqPdXv0lOwy06YAzH/ImTVLUFguUP71XxwJgX5+o16wR43kf1HWXqH2SwmSmwIXNUgrruWkZn2pPPoAkDgd6Dpgx9CxHu+J6/8ngLYbzPE64xOtsQA0+pNXNgRWN844eihYLM5ThvoA9cnNGuvxs9QS+FNy0g9/zx8HaJfuI7TPbrvmEDV1Wo/u1owQsBoLPnyXOrPB4oUuQEQRGGFlp/O9L3irmXiBuDzZ1i7p2CzNmRpan9T15ni1nIuryzxWuC1rFM=" - ], - "priority": ["100"] - } - }, - { - "id": "74e51358-7cdd-4d46-82ea-2fc3588871db", - "name": "hmac-generated", - "providerId": "hmac-generated", - "subComponents": {}, - "config": { - "kid": ["caf49dff-5c1a-40c6-9517-158bfe952c2b"], - "secret": [ - "gOJfXvMa_5RCFneAhJQ_l4Zbp0Nvx5_TH5vAzea3CaxpcFd9M6aRJkxIYxW0ou1Nm5a9By2fmOHimVGvO52y3A" - ], - "priority": ["100"], - "algorithm": ["HS256"] - } - }, - { - "id": "10d84950-4825-4d9e-b611-2918ba2c1dc0", - "name": "aes-generated", - "providerId": "aes-generated", - "subComponents": {}, - "config": { - "kid": ["9f340647-7aa9-4ae5-b105-df634bef7fec"], - "secret": ["AStMPfNExU1W6b0UT6LaRw"], - "priority": ["100"] - } - } - ] + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + } ], + "clientScopes" : [ { + "id" : "430142db-2fac-4c47-a4d1-0893d0918da4", + "name" : "address", + "description" : "OpenID Connect built-in scope: address", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${addressScopeConsentText}" }, - "internationalizationEnabled": false, - "supportedLocales": [], - "authenticationFlows": [ - { - "id": "6470c1c9-c60d-44c4-8855-32b16ddccffd", - "alias": "Account verification options", - "description": "Method with which to verity the existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-email-verification", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 20, - "flowAlias": "Verify Existing Account by Re-authentication", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "3173ffa5-b008-4703-90b7-0ebb015f2be7", - "alias": "Authentication Options", - "description": "Authentication options.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "basic-auth", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "basic-auth-otp", - "requirement": "DISABLED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-spnego", - "requirement": "DISABLED", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "adb6ee43-295d-4f16-941e-ef69a5d358af", - "alias": "Browser - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-otp-form", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "a709b82a-d8d4-4b3c-b740-f035e91e981d", - "alias": "Direct Grant - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "direct-grant-validate-otp", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "c7af99f9-0c98-40ea-b03a-29dc765dbe7e", - "alias": "First broker login - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-otp-form", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "2b0997a3-7021-4783-838e-bd66456e060e", - "alias": "Handle Existing Account", - "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-confirm-link", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "Account verification options", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "d7dfd378-7e4c-47a7-babd-488f6df92bf4", - "alias": "Reset - Conditional OTP", - "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-otp", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "659d2072-58f1-4aff-9fa7-2ee9b784f637", - "alias": "User creation or linking", - "description": "Flow for the existing/non-existing user alternatives", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "create unique user config", - "authenticator": "idp-create-user-if-unique", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 20, - "flowAlias": "Handle Existing Account", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "caab9ee8-0251-469e-9c31-0f1405f372ab", - "alias": "Verify Existing Account by Re-authentication", - "description": "Reauthentication of existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-username-password-form", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 20, - "flowAlias": "First broker login - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "9226714e-78ff-4f1d-89bc-bb4b19be7b35", - "alias": "browser", - "description": "browser based authentication", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-spnego", - "requirement": "DISABLED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "identity-provider-redirector", - "requirement": "ALTERNATIVE", - "priority": 25, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 30, - "flowAlias": "forms", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "c429f93d-aaae-4bee-ae46-d9ea45756c14", - "alias": "clients", - "description": "Base authentication for clients", - "providerId": "client-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "client-secret", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-jwt", - "requirement": "ALTERNATIVE", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-secret-jwt", - "requirement": "ALTERNATIVE", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-x509", - "requirement": "ALTERNATIVE", - "priority": 40, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "158fe2f0-0fe5-4cb9-809e-0fcf4f57aad3", - "alias": "direct grant", - "description": "OpenID Connect Resource Owner Grant", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "direct-grant-validate-username", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "direct-grant-validate-password", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 30, - "flowAlias": "Direct Grant - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "95807305-f622-42a2-9a9d-dcf575c137b0", - "alias": "docker auth", - "description": "Used by Docker clients to authenticate against the IDP", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "docker-http-basic-authenticator", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "b1ce3f82-01d6-4f6e-8921-c4731429f682", - "alias": "first broker login", - "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "review profile config", - "authenticator": "idp-review-profile", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "User creation or linking", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "f6d84af5-2172-409a-8f88-429e534d87dc", - "alias": "forms", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 20, - "flowAlias": "Browser - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "da67719a-dad8-4743-962e-3923b178b172", - "alias": "http challenge", - "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "no-cookie-redirect", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "Authentication Options", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "b835f804-19f1-46ad-8807-35c5ccf389c8", - "alias": "registration", - "description": "registration flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-page-form", - "requirement": "REQUIRED", - "priority": 10, - "flowAlias": "registration form", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "2468b1d2-1c96-43ae-b531-74786b329093", - "alias": "registration form", - "description": "registration form", - "providerId": "form-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-user-creation", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-profile-action", - "requirement": "REQUIRED", - "priority": 40, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-password-action", - "requirement": "REQUIRED", - "priority": 50, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-recaptcha-action", - "requirement": "DISABLED", - "priority": 60, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "cde71745-bd53-49f3-ab96-ed3ca3f150e1", - "alias": "reset credentials", - "description": "Reset credentials for a user if they forgot their password or something", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "reset-credentials-choose-user", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-credential-email", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-password", - "requirement": "REQUIRED", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 40, - "flowAlias": "Reset - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "8a6a0b09-7405-4422-9e00-dabcea2e75b9", - "alias": "saml ecp", - "description": "SAML ECP Profile Authentication Flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "http-basic-authenticator", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] + "protocolMappers" : [ { + "id" : "b964306d-914a-4442-8445-f3cc43bf4ef4", + "name" : "address", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-address-mapper", + "consentRequired" : false, + "config" : { + "user.attribute.formatted" : "formatted", + "user.attribute.country" : "country", + "user.attribute.postal_code" : "postal_code", + "userinfo.token.claim" : "true", + "user.attribute.street" : "street", + "id.token.claim" : "true", + "user.attribute.region" : "region", + "access.token.claim" : "true", + "user.attribute.locality" : "locality" + } + } ] + }, { + "id" : "b86d691d-c6b7-45b2-993f-c4f3c6ed9f21", + "name" : "email", + "description" : "OpenID Connect built-in scope: email", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${emailScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "caf47f60-07c5-4975-a2e2-709aa6aee27e", + "name" : "email verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "emailVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email_verified", + "jsonType.label" : "boolean" } - ], - "authenticatorConfig": [ - { - "id": "820299ec-5edc-4914-bad3-4e95e2a8f3cf", - "alias": "create unique user config", - "config": { - "require.password.update.after.registration": "false" - } - }, - { - "id": "699a8e9f-5d25-48fc-996f-11b0a5bc2d66", - "alias": "review profile config", - "config": { - "update.profile.on.first.login": "missing" - } + }, { + "id" : "6521805d-46b1-4fb9-988b-ae4d13e1b8e5", + "name" : "email", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "email", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email", + "jsonType.label" : "String" } - ], - "requiredActions": [ - { - "alias": "CONFIGURE_TOTP", - "name": "Configure OTP", - "providerId": "CONFIGURE_TOTP", - "enabled": true, - "defaultAction": false, - "priority": 10, - "config": {} - }, - { - "alias": "terms_and_conditions", - "name": "Terms and Conditions", - "providerId": "terms_and_conditions", - "enabled": false, - "defaultAction": false, - "priority": 20, - "config": {} - }, - { - "alias": "UPDATE_PASSWORD", - "name": "Update Password", - "providerId": "UPDATE_PASSWORD", - "enabled": true, - "defaultAction": false, - "priority": 30, - "config": {} - }, - { - "alias": "UPDATE_PROFILE", - "name": "Update Profile", - "providerId": "UPDATE_PROFILE", - "enabled": true, - "defaultAction": false, - "priority": 40, - "config": {} - }, - { - "alias": "VERIFY_EMAIL", - "name": "Verify Email", - "providerId": "VERIFY_EMAIL", - "enabled": true, - "defaultAction": false, - "priority": 50, - "config": {} - }, - { - "alias": "update_user_locale", - "name": "Update User Locale", - "providerId": "update_user_locale", - "enabled": true, - "defaultAction": false, - "priority": 1000, - "config": {} + } ] + }, { + "id" : "a28910e0-e6b4-4d2b-9fa0-bb6b2c6ea78a", + "name" : "microprofile-jwt", + "description" : "Microprofile - JWT built-in scope", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "d0b1f606-d23e-4762-9d29-24a1f060c879", + "name" : "groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "multivalued" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "foo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "jsonType.label" : "String" } - ], - "browserFlow": "browser", - "registrationFlow": "registration", - "directGrantFlow": "direct grant", - "resetCredentialsFlow": "reset credentials", - "clientAuthenticationFlow": "clients", - "dockerAuthenticationFlow": "docker auth", - "attributes": {}, - "keycloakVersion": "10.0.1", - "userManagedAccessAllowed": false - }, - { - "id": "timed", - "realm": "timed", - "notBefore": 0, - "revokeRefreshToken": false, - "refreshTokenMaxReuse": 0, - "accessTokenLifespan": 300, - "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, - "ssoSessionIdleTimeoutRememberMe": 0, - "ssoSessionMaxLifespanRememberMe": 0, - "offlineSessionIdleTimeout": 2592000, - "offlineSessionMaxLifespanEnabled": false, - "offlineSessionMaxLifespan": 5184000, - "clientSessionIdleTimeout": 0, - "clientSessionMaxLifespan": 0, - "accessCodeLifespan": 60, - "accessCodeLifespanUserAction": 300, - "accessCodeLifespanLogin": 1800, - "actionTokenGeneratedByAdminLifespan": 43200, - "actionTokenGeneratedByUserLifespan": 300, - "enabled": true, - "sslRequired": "external", - "registrationAllowed": false, - "registrationEmailAsUsername": false, - "rememberMe": false, - "verifyEmail": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "bruteForceProtected": false, - "permanentLockout": false, - "maxFailureWaitSeconds": 900, - "minimumQuickLoginWaitSeconds": 60, - "waitIncrementSeconds": 60, - "quickLoginCheckMilliSeconds": 1000, - "maxDeltaTimeSeconds": 43200, - "failureFactor": 30, - "roles": { - "realm": [ - { - "id": "9ff0d967-aa79-4bf2-8a90-7bd89f66d73c", - "name": "offline_access", - "description": "${role_offline-access}", - "composite": false, - "clientRole": false, - "containerId": "timed", - "attributes": {} - }, - { - "id": "d99bb30b-f4d2-48f4-a053-706e42c4f7ee", - "name": "uma_authorization", - "description": "${role_uma_authorization}", - "composite": false, - "clientRole": false, - "containerId": "timed", - "attributes": {} - } - ], - "client": { - "realm-management": [ - { - "id": "57a2fe69-be5f-444f-b65e-6c7f03f8d869", - "name": "realm-admin", - "description": "${role_realm-admin}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "query-realms", - "create-client", - "manage-events", - "view-authorization", - "manage-authorization", - "view-identity-providers", - "query-groups", - "view-clients", - "manage-identity-providers", - "view-realm", - "query-clients", - "query-users", - "manage-clients", - "view-events", - "manage-users", - "impersonation", - "view-users", - "manage-realm" - ] - } - }, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "da8f521e-a54b-4870-802c-ea03dc9912b1", - "name": "create-client", - "description": "${role_create-client}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "b631d90e-1240-45d7-9b80-2e56c476d682", - "name": "query-realms", - "description": "${role_query-realms}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "d718e12e-eb6b-4876-be82-46d8404955cb", - "name": "manage-events", - "description": "${role_manage-events}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "a0685af3-f0b8-4cca-ba68-43ac45f4d9a4", - "name": "view-authorization", - "description": "${role_view-authorization}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "885d9844-528e-4ce5-a3e4-97d772c21f83", - "name": "manage-authorization", - "description": "${role_manage-authorization}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "6df9ee52-f586-4464-b088-a7a9c1f5728f", - "name": "view-identity-providers", - "description": "${role_view-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "f0c7d896-ea82-41f6-920d-6b3796e4c749", - "name": "query-groups", - "description": "${role_query-groups}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "a5670fbb-438d-49b1-b27d-9ddcfe429bea", - "name": "view-clients", - "description": "${role_view-clients}", - "composite": true, - "composites": { - "client": { - "realm-management": ["query-clients"] - } - }, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "1b54aaa2-6b4d-4743-8967-af332be88f3c", - "name": "manage-identity-providers", - "description": "${role_manage-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "67b6cc9f-8496-40d0-a20a-05a3956247dc", - "name": "view-realm", - "description": "${role_view-realm}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "a88cfadc-008c-4be0-ab43-d41e86a477bd", - "name": "query-clients", - "description": "${role_query-clients}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "3eceb96d-7c30-44c5-b24b-f187f2354a81", - "name": "query-users", - "description": "${role_query-users}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "ee0d161a-ca6b-41c0-bc5f-649241374909", - "name": "manage-clients", - "description": "${role_manage-clients}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "1f37d868-c309-4e66-9172-4f79c4717cfa", - "name": "view-events", - "description": "${role_view-events}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "3bb25834-99d1-42da-a548-e1c2ac345f55", - "name": "manage-users", - "description": "${role_manage-users}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "eec090c0-32de-4af5-a30c-6caddc24f234", - "name": "view-users", - "description": "${role_view-users}", - "composite": true, - "composites": { - "client": { - "realm-management": ["query-users", "query-groups"] - } - }, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "978bf5c9-f072-483f-a812-1ac7435c32a3", - "name": "impersonation", - "description": "${role_impersonation}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - }, - { - "id": "7bde3002-188f-4f9e-a2ae-791594539429", - "name": "manage-realm", - "description": "${role_manage-realm}", - "composite": false, - "clientRole": true, - "containerId": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "attributes": {} - } - ], - "timed-public": [], - "security-admin-console": [], - "admin-cli": [], - "account-console": [], - "broker": [ - { - "id": "9d18b1f6-81a4-45e8-b80d-a7f413435e35", - "name": "read-token", - "description": "${role_read-token}", - "composite": false, - "clientRole": true, - "containerId": "1061a27d-9138-4b16-8d34-5aca38a99881", - "attributes": {} - } - ], - "account": [ - { - "id": "2e91b70b-71d0-43a4-aff2-d511ecbe336b", - "name": "manage-consent", - "description": "${role_manage-consent}", - "composite": true, - "composites": { - "client": { - "account": ["view-consent"] - } - }, - "clientRole": true, - "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", - "attributes": {} - }, - { - "id": "d4cb3e12-deb9-4d08-998b-80d81d59e32a", - "name": "view-consent", - "description": "${role_view-consent}", - "composite": false, - "clientRole": true, - "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", - "attributes": {} - }, - { - "id": "ed5043f6-5896-44c8-b5d1-604f3963dde4", - "name": "view-profile", - "description": "${role_view-profile}", - "composite": false, - "clientRole": true, - "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", - "attributes": {} - }, - { - "id": "4f96be37-f919-49ee-82c5-5bd5b8b45216", - "name": "view-applications", - "description": "${role_view-applications}", - "composite": false, - "clientRole": true, - "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", - "attributes": {} - }, - { - "id": "a66d557e-5e7b-40c2-9b42-9c92c6f7994a", - "name": "manage-account", - "description": "${role_manage-account}", - "composite": true, - "composites": { - "client": { - "account": ["manage-account-links"] - } - }, - "clientRole": true, - "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", - "attributes": {} - }, - { - "id": "285e9a4b-523c-4455-b59c-8d599f5c0d77", - "name": "manage-account-links", - "description": "${role_manage-account-links}", - "composite": false, - "clientRole": true, - "containerId": "fca38486-0dca-4d9f-aebc-d219d641e179", - "attributes": {} - } - ] + }, { + "id" : "349b94d1-e9d9-464e-93da-fdc6c715037f", + "name" : "upn", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "upn", + "jsonType.label" : "String" } + } ] + }, { + "id" : "60b03eb6-b050-4454-a337-f8928575ee7d", + "name" : "offline_access", + "description" : "OpenID Connect built-in scope: offline_access", + "protocol" : "openid-connect", + "attributes" : { + "consent.screen.text" : "${offlineAccessScopeConsentText}", + "display.on.consent.screen" : "true" + } + }, { + "id" : "fca7028e-0e94-4c09-988e-e661d46482b9", + "name" : "phone", + "description" : "OpenID Connect built-in scope: phone", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${phoneScopeConsentText}" }, - "groups": [], - "defaultRoles": ["offline_access", "uma_authorization"], - "requiredCredentials": ["password"], - "otpPolicyType": "totp", - "otpPolicyAlgorithm": "HmacSHA1", - "otpPolicyInitialCounter": 0, - "otpPolicyDigits": 6, - "otpPolicyLookAheadWindow": 1, - "otpPolicyPeriod": 30, - "otpSupportedApplications": ["FreeOTP", "Google Authenticator"], - "webAuthnPolicyRpEntityName": "keycloak", - "webAuthnPolicySignatureAlgorithms": ["ES256"], - "webAuthnPolicyRpId": "", - "webAuthnPolicyAttestationConveyancePreference": "not specified", - "webAuthnPolicyAuthenticatorAttachment": "not specified", - "webAuthnPolicyRequireResidentKey": "not specified", - "webAuthnPolicyUserVerificationRequirement": "not specified", - "webAuthnPolicyCreateTimeout": 0, - "webAuthnPolicyAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyAcceptableAaguids": [], - "webAuthnPolicyPasswordlessRpEntityName": "keycloak", - "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], - "webAuthnPolicyPasswordlessRpId": "", - "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", - "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", - "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", - "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", - "webAuthnPolicyPasswordlessCreateTimeout": 0, - "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyPasswordlessAcceptableAaguids": [], - "scopeMappings": [ - { - "clientScope": "offline_access", - "roles": ["offline_access"] + "protocolMappers" : [ { + "id" : "a1feecd1-a101-444a-b8d1-c4a459e7ea6d", + "name" : "phone number", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumber", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number", + "jsonType.label" : "String" + } + }, { + "id" : "301418c7-ab4e-4a8b-a13f-331e1ab094f3", + "name" : "phone number verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumberVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number_verified", + "jsonType.label" : "boolean" } - ], - "clientScopeMappings": { - "account": [ - { - "client": "account-console", - "roles": ["manage-account"] - } - ] + } ] + }, { + "id" : "d8c1471f-1e13-4e37-9639-40257ca784cc", + "name" : "profile", + "description" : "OpenID Connect built-in scope: profile", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${profileScopeConsentText}" }, - "clients": [ - { - "id": "fca38486-0dca-4d9f-aebc-d219d641e179", - "clientId": "account", - "name": "${client_account}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/timed/account/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "731474de-9346-457a-a514-888887f78683", - "defaultRoles": ["manage-account", "view-profile"], - "redirectUris": ["/realms/timed/account/*"], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "0953aa7e-fac7-43c9-aba9-83ce83d91c7c", - "clientId": "account-console", - "name": "${client_account-console}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/timed/account/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "ec538ba5-bdd1-4f84-918d-90fc5e89874c", - "redirectUris": ["/realms/timed/account/*"], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "pkce.code.challenge.method": "S256" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "protocolMappers": [ - { - "id": "2cd22dff-9478-416a-99fe-b2b70d18ca72", - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", - "consentRequired": false, - "config": {} - } - ], - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "63a6baba-3d47-4308-af73-7c763fd31cfd", - "clientId": "admin-cli", - "name": "${client_admin-cli}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "97bb38b7-d47c-4d37-88c2-7fed912b1f8b", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": false, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "1061a27d-9138-4b16-8d34-5aca38a99881", - "clientId": "broker", - "name": "${client_broker}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "66d4120f-76dc-49fe-a958-7983be2aeee8", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "ea8482c9-4974-4761-afb0-c092d89b5a0e", - "clientId": "realm-management", - "name": "${client_realm-management}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "e4205e45-7122-4cb5-a3fd-c63cc58ee0a1", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": true, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "aa1b6e9c-92fe-426b-9eb0-23041d027c2d", - "clientId": "security-admin-console", - "name": "${client_security-admin-console}", - "rootUrl": "${authAdminUrl}", - "baseUrl": "/admin/timed/console/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "99ae8d54-620c-4bfb-9447-62dbbce5786b", - "redirectUris": ["/admin/timed/console/*"], - "webOrigins": ["+"], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "pkce.code.challenge.method": "S256" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "protocolMappers": [ - { - "id": "09f41dd1-e9f6-44eb-b847-0532ea9ea522", - "name": "locale", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "locale", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" - } - } - ], - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "id": "30472c69-7906-44be-acbc-619d1cb7d183", - "clientId": "timed-public", - "rootUrl": "http://timed.local", - "adminUrl": "http://timed.local", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "bde8e0d9-c4f8-4ab6-b1db-4724b85e8db0", - "redirectUris": ["http://timed.local/*"], - "webOrigins": ["*"], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "saml.assertion.signature": "false", - "saml.force.post.binding": "false", - "saml.multivalued.roles": "false", - "saml.encrypt": "false", - "saml.server.signature": "false", - "saml.server.signature.keyinfo.ext": "false", - "exclude.session.state.from.auth.response": "false", - "saml_force_name_id_format": "false", - "saml.client.signature": "false", - "tls.client.certificate.bound.access.tokens": "false", - "saml.authnstatement": "false", - "display.on.consent.screen": "false", - "saml.onetimeuse.condition": "false" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] + "protocolMappers" : [ { + "id" : "8e2378db-ebe8-4141-af99-a7d8cbfb31bd", + "name" : "username", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "preferred_username", + "jsonType.label" : "String" } - ], - "clientScopes": [ - { - "id": "430142db-2fac-4c47-a4d1-0893d0918da4", - "name": "address", - "description": "OpenID Connect built-in scope: address", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${addressScopeConsentText}" - }, - "protocolMappers": [ - { - "id": "b964306d-914a-4442-8445-f3cc43bf4ef4", - "name": "address", - "protocol": "openid-connect", - "protocolMapper": "oidc-address-mapper", - "consentRequired": false, - "config": { - "user.attribute.formatted": "formatted", - "user.attribute.country": "country", - "user.attribute.postal_code": "postal_code", - "userinfo.token.claim": "true", - "user.attribute.street": "street", - "id.token.claim": "true", - "user.attribute.region": "region", - "access.token.claim": "true", - "user.attribute.locality": "locality" - } - } - ] - }, - { - "id": "b86d691d-c6b7-45b2-993f-c4f3c6ed9f21", - "name": "email", - "description": "OpenID Connect built-in scope: email", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${emailScopeConsentText}" - }, - "protocolMappers": [ - { - "id": "caf47f60-07c5-4975-a2e2-709aa6aee27e", - "name": "email verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "emailVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email_verified", - "jsonType.label": "boolean" - } - }, - { - "id": "6521805d-46b1-4fb9-988b-ae4d13e1b8e5", - "name": "email", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "email", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "a28910e0-e6b4-4d2b-9fa0-bb6b2c6ea78a", - "name": "microprofile-jwt", - "description": "Microprofile - JWT built-in scope", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "d0b1f606-d23e-4762-9d29-24a1f060c879", - "name": "groups", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "multivalued": "true", - "user.attribute": "foo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "groups", - "jsonType.label": "String" - } - }, - { - "id": "349b94d1-e9d9-464e-93da-fdc6c715037f", - "name": "upn", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "upn", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "60b03eb6-b050-4454-a337-f8928575ee7d", - "name": "offline_access", - "description": "OpenID Connect built-in scope: offline_access", - "protocol": "openid-connect", - "attributes": { - "consent.screen.text": "${offlineAccessScopeConsentText}", - "display.on.consent.screen": "true" - } - }, - { - "id": "fca7028e-0e94-4c09-988e-e661d46482b9", - "name": "phone", - "description": "OpenID Connect built-in scope: phone", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${phoneScopeConsentText}" - }, - "protocolMappers": [ - { - "id": "a1feecd1-a101-444a-b8d1-c4a459e7ea6d", - "name": "phone number", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "phoneNumber", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number", - "jsonType.label": "String" - } - }, - { - "id": "301418c7-ab4e-4a8b-a13f-331e1ab094f3", - "name": "phone number verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "phoneNumberVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number_verified", - "jsonType.label": "boolean" - } - } - ] - }, - { - "id": "d8c1471f-1e13-4e37-9639-40257ca784cc", - "name": "profile", - "description": "OpenID Connect built-in scope: profile", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${profileScopeConsentText}" - }, - "protocolMappers": [ - { - "id": "8e2378db-ebe8-4141-af99-a7d8cbfb31bd", - "name": "username", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "preferred_username", - "jsonType.label": "String" - } - }, - { - "id": "9aea5061-7e49-4a9f-9dd6-b0ec6aa31a33", - "name": "nickname", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "nickname", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "nickname", - "jsonType.label": "String" - } - }, - { - "id": "63233679-5038-434f-9cf1-1b4cac25eb9d", - "name": "family name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "lastName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "family_name", - "jsonType.label": "String" - } - }, - { - "id": "0e4034e3-4bf8-45a6-9b03-5fe58406a235", - "name": "middle name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "middleName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "middle_name", - "jsonType.label": "String" - } - }, - { - "id": "db51ecec-798e-4407-a750-2eba3c74bf5c", - "name": "gender", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "gender", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "gender", - "jsonType.label": "String" - } - }, - { - "id": "f66647a8-cc03-4936-a4c8-a2d43f2fb605", - "name": "locale", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "locale", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" - } - }, - { - "id": "a581ee00-615b-4ae2-9123-14889580a95b", - "name": "profile", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "profile", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "profile", - "jsonType.label": "String" - } - }, - { - "id": "30bec6a9-f8c3-4a50-9a1b-d07cae3df8d8", - "name": "picture", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "picture", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "picture", - "jsonType.label": "String" - } - }, - { - "id": "16ac837d-ff47-4052-9a68-8ed6b399ec2b", - "name": "birthdate", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "birthdate", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "birthdate", - "jsonType.label": "String" - } - }, - { - "id": "82c27e74-153a-47b2-8148-71af806b1f99", - "name": "updated at", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "updatedAt", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "updated_at", - "jsonType.label": "String" - } - }, - { - "id": "83701e99-d46b-4ddb-9e87-696d41a97984", - "name": "website", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "website", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "website", - "jsonType.label": "String" - } - }, - { - "id": "69977a6d-5238-4385-a922-0c1b99f1c15a", - "name": "zoneinfo", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "zoneinfo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "zoneinfo", - "jsonType.label": "String" - } - }, - { - "id": "aa8e440f-7812-408f-b55c-a154337aae2d", - "name": "full name", - "protocol": "openid-connect", - "protocolMapper": "oidc-full-name-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }, - { - "id": "84c655de-34d7-4d58-a63c-9c78475da136", - "name": "given name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "firstName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "given_name", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "8e350b44-837b-42f1-9cf2-445f282ea9da", - "name": "role_list", - "description": "SAML role list", - "protocol": "saml", - "attributes": { - "consent.screen.text": "${samlRoleListScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "78f6f58f-4cd6-4635-ba1c-e8f7c9ec29f1", - "name": "role list", - "protocol": "saml", - "protocolMapper": "saml-role-list-mapper", - "consentRequired": false, - "config": { - "single": "false", - "attribute.nameformat": "Basic", - "attribute.name": "Role" - } - } - ] - }, - { - "id": "93d7c992-6651-4300-9b85-832e8929bce4", - "name": "roles", - "description": "OpenID Connect scope for add user roles to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "true", - "consent.screen.text": "${rolesScopeConsentText}" - }, - "protocolMappers": [ - { - "id": "e7134f4a-d194-4dfc-bf3e-40de3b3bf087", - "name": "client roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-client-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "access.token.claim": "true", - "claim.name": "resource_access.${client_id}.roles", - "jsonType.label": "String", - "multivalued": "true" - } - }, - { - "id": "936536a3-9b53-44a5-ad90-1e82871c93e8", - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", - "consentRequired": false, - "config": {} - }, - { - "id": "275e146a-73cc-4930-a752-2bd18764f65b", - "name": "realm roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "access.token.claim": "true", - "claim.name": "realm_access.roles", - "jsonType.label": "String", - "multivalued": "true" - } - } - ] - }, - { - "id": "17745df8-9dda-4ded-b2e1-10c10892a341", - "name": "web-origins", - "description": "OpenID Connect scope for add allowed web origins to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false", - "consent.screen.text": "" - }, - "protocolMappers": [ - { - "id": "15a58940-f2e8-4032-bdf0-f5d586fa86e1", - "name": "allowed web origins", - "protocol": "openid-connect", - "protocolMapper": "oidc-allowed-origins-mapper", - "consentRequired": false, - "config": {} - } - ] + }, { + "id" : "9aea5061-7e49-4a9f-9dd6-b0ec6aa31a33", + "name" : "nickname", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "nickname", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "nickname", + "jsonType.label" : "String" + } + }, { + "id" : "63233679-5038-434f-9cf1-1b4cac25eb9d", + "name" : "family name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "lastName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "family_name", + "jsonType.label" : "String" + } + }, { + "id" : "0e4034e3-4bf8-45a6-9b03-5fe58406a235", + "name" : "middle name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "middleName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "middle_name", + "jsonType.label" : "String" } - ], - "defaultDefaultClientScopes": [ - "role_list", - "profile", - "email", - "roles", - "web-origins" - ], - "defaultOptionalClientScopes": [ - "offline_access", - "address", - "phone", - "microprofile-jwt" - ], - "browserSecurityHeaders": { - "contentSecurityPolicyReportOnly": "", - "xContentTypeOptions": "nosniff", - "xRobotsTag": "none", - "xFrameOptions": "SAMEORIGIN", - "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", - "xXSSProtection": "1; mode=block", - "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, { + "id" : "db51ecec-798e-4407-a750-2eba3c74bf5c", + "name" : "gender", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "gender", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "gender", + "jsonType.label" : "String" + } + }, { + "id" : "f66647a8-cc03-4936-a4c8-a2d43f2fb605", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + }, { + "id" : "a581ee00-615b-4ae2-9123-14889580a95b", + "name" : "profile", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "profile", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "profile", + "jsonType.label" : "String" + } + }, { + "id" : "30bec6a9-f8c3-4a50-9a1b-d07cae3df8d8", + "name" : "picture", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "picture", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "picture", + "jsonType.label" : "String" + } + }, { + "id" : "16ac837d-ff47-4052-9a68-8ed6b399ec2b", + "name" : "birthdate", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "birthdate", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "birthdate", + "jsonType.label" : "String" + } + }, { + "id" : "82c27e74-153a-47b2-8148-71af806b1f99", + "name" : "updated at", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "updatedAt", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "updated_at", + "jsonType.label" : "String" + } + }, { + "id" : "83701e99-d46b-4ddb-9e87-696d41a97984", + "name" : "website", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "website", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "website", + "jsonType.label" : "String" + } + }, { + "id" : "69977a6d-5238-4385-a922-0c1b99f1c15a", + "name" : "zoneinfo", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "zoneinfo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "zoneinfo", + "jsonType.label" : "String" + } + }, { + "id" : "aa8e440f-7812-408f-b55c-a154337aae2d", + "name" : "full name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-full-name-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "access.token.claim" : "true", + "userinfo.token.claim" : "true" + } + }, { + "id" : "84c655de-34d7-4d58-a63c-9c78475da136", + "name" : "given name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "firstName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "given_name", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "8e350b44-837b-42f1-9cf2-445f282ea9da", + "name" : "role_list", + "description" : "SAML role list", + "protocol" : "saml", + "attributes" : { + "consent.screen.text" : "${samlRoleListScopeConsentText}", + "display.on.consent.screen" : "true" }, - "smtpServer": {}, - "eventsEnabled": false, - "eventsListeners": ["jboss-logging"], - "enabledEventTypes": [], - "adminEventsEnabled": false, - "adminEventsDetailsEnabled": false, - "components": { - "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ - { - "id": "0c68916d-29e5-4bc3-8f34-66d3e074d0ba", - "name": "Trusted Hosts", - "providerId": "trusted-hosts", - "subType": "anonymous", - "subComponents": {}, - "config": { - "host-sending-registration-request-must-match": ["true"], - "client-uris-must-match": ["true"] - } - }, - { - "id": "01017e9d-ccee-4682-a43d-dedc779456df", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "saml-user-attribute-mapper", - "oidc-sha256-pairwise-sub-mapper", - "saml-role-list-mapper", - "oidc-address-mapper", - "oidc-usermodel-property-mapper", - "oidc-full-name-mapper", - "saml-user-property-mapper", - "oidc-usermodel-attribute-mapper" - ] - } - }, - { - "id": "04a61e10-fc46-4e0c-ad0a-3eec163d6e24", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allow-default-scopes": ["true"] - } - }, - { - "id": "aae5ad0e-f00b-4ebf-8b7f-dac3e020d5d5", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allow-default-scopes": ["true"] - } - }, - { - "id": "cae2d534-9164-4760-a10d-19cfdf36ac0d", - "name": "Consent Required", - "providerId": "consent-required", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "id": "cb3d8398-9fda-423a-bc1a-9504ead9d10f", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "saml-user-attribute-mapper", - "saml-user-property-mapper", - "oidc-sha256-pairwise-sub-mapper", - "oidc-full-name-mapper", - "saml-role-list-mapper", - "oidc-usermodel-property-mapper", - "oidc-usermodel-attribute-mapper", - "oidc-address-mapper" - ] - } - }, - { - "id": "65750361-be2e-41cc-ae63-1abdda285720", - "name": "Full Scope Disabled", - "providerId": "scope", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "id": "f1669b22-e29d-4eda-97b3-f4ab3ec57673", - "name": "Max Clients Limit", - "providerId": "max-clients", - "subType": "anonymous", - "subComponents": {}, - "config": { - "max-clients": ["200"] - } - } - ], - "org.keycloak.keys.KeyProvider": [ - { - "id": "ffd05084-ba7b-4d5f-bec3-9a2c9cabb7eb", - "name": "rsa-generated", - "providerId": "rsa-generated", - "subComponents": {}, - "config": { - "privateKey": [ - "MIIEowIBAAKCAQEAmKi1AOCgn2rmzdroi54yETw7vo7sVFoQsKYf3L68TgLMjHxsjZLj7o6aVi9wv3rZ0krm7JBD3nxcGSPyEkBGYwaGzO0qxSnjkEfqRFxOODjWcQglcfycY3JyQE8Uz2bJ45BQ3lebviP6AEF3RW0xmuiQ6BKr26sK8iRa7ivZH2SbLsKMVlR/7kCHNGnCnCqWXuCmMMItGT0eqclbnT0BBKBN6aTou7Nh95Uj3Iwh9EfPUIQjnrRK9YOCdh3mjvuadojiPWUvRkVOOGg9yBU32J/WKJUbO4qp5VsJGkSwBPgN5rUa0np1vP2WwDihPIFbQMUqm+ZBOOCbhbYMx4KzXwIDAQABAoIBAQCOdNi74eJiAZsyPHbHWy+zn7bM44isSoPKpKuVDjSgw8Hn03BlSM8E3fQuOwUG2niL4jPOS+3Zn8k9+Ko719kXLY77itJfvPBLwqBdfJnNo1SRlB2FWksCDlmJo4Jy7KO3hQPCCJUggWgZdv37PqOMwDwBJPNVAS8suTpViXuK66EcDsB2m4R+rRXqVXz2w20CSCT1zathxEIQsBBxKR4lJHBgCE6GDwGf7SZGIxLgQhnnYUib3rSh3RyHoexPwPUjhQmJIVPWK1krRwpW93QXGL0wWXAvROyM0kg3Qd8evqqfkPHtO+zqnYa632l93DhgeESykTImxTykPcHxz2kJAoGBAM2EXe4CiLGLbjmYxatXOpQmI8rmsskvL6FE5tjYHh2XF57U3OoNhdHyU0H7Qz8z3mRj2irwRHG6QhjH+yU1TSWgiaPLc9rZ7PE4tRCOkG1c9vjMdvexOrXs16FUY1xWXMwVhvKlZOO3t1D7yzQvzibYyrkvHST61u+bf+fbtIW1AoGBAL4ocN1HPhcro9DTyiK/AJgntnwNA4jv6QwzvK+hPqd+DJoVHhBghbHhhdpZKiPRC/2nCLuiBSNmXzG2Awapn6JoBely319InxvKrPZOFcvOxKVHw1XDtrkUaL6yM6EgvLsHU8yR8Ov6gaLdg2NbpfA+VXL1KxvVDois8s/+hgFDAoGAWMiOK3w8wTaS757oBhUw4T94xvbS1cbktK6na5YxrGbRdXRP22zsGr6s6Rw6+NrXgFcCsPoLF3Z3h20dOf3EzjSEQZZq/miWy77LudNc4WH/74uk+Ww/CMjAfpmOMx28CQ5jtf9tjlKXhwy/xFPCo1WUflu0I32ZzPlIUEnBuuECgYAhqeMhKUWSsIUVqQi10f528UDbASrJCT/GizoyFWeUGzp75JUn7Q5+CSC7IOHW6WEoDHP9U5d5Rtw/Xqt2eHzsMWIqi82DfsW8E8s+51/wbrBdWjD4c+dbKIPKjp2ZPsRqj8eEBaoS/IwKmxBxfH4J498YtNJm4Pbrt0JdFAABJQKBgHFqGu3a5cCbcuo0wDPyvibq1BcFVQSgXHBhztu8LPRHTo3BzACrVEN3MX3H+uzafb0YHDb/tpPYw/RWC8luE1CrKSr9l/qG6r3nNaTnG7rxj2dRffsyXuUwSvc+DCu1aTR2Zk/uMoH1pr66cM5YjwVzQcuPA820+1BXyFsyGDy9" - ], - "certificate": [ - "MIICmTCCAYECBgFyVvIFkjANBgkqhkiG9w0BAQsFADAQMQ4wDAYDVQQDDAV0aW1lZDAeFw0yMDA1MjcxNjIxNDFaFw0zMDA1MjcxNjIzMjFaMBAxDjAMBgNVBAMMBXRpbWVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmKi1AOCgn2rmzdroi54yETw7vo7sVFoQsKYf3L68TgLMjHxsjZLj7o6aVi9wv3rZ0krm7JBD3nxcGSPyEkBGYwaGzO0qxSnjkEfqRFxOODjWcQglcfycY3JyQE8Uz2bJ45BQ3lebviP6AEF3RW0xmuiQ6BKr26sK8iRa7ivZH2SbLsKMVlR/7kCHNGnCnCqWXuCmMMItGT0eqclbnT0BBKBN6aTou7Nh95Uj3Iwh9EfPUIQjnrRK9YOCdh3mjvuadojiPWUvRkVOOGg9yBU32J/WKJUbO4qp5VsJGkSwBPgN5rUa0np1vP2WwDihPIFbQMUqm+ZBOOCbhbYMx4KzXwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQA/52H5X2MAH6aw445RbeU6fVYnKJu4hBkcIGHPjKR++Gq54M6bcbnNKNunhVbGZUqdPHX4+ktnQSZauq2hd9HRcezhL3OlmtEnLNW8BoFPaB11gmdjOiVQGm6iJsSJGxrrO7q3YzY+IB/ZTZlWmuOGnUDprFxDv0LR3M8X0ls0ygmw4/CmXZ6Fhg43Ey27ZArlSmzohAq8YGpV8HEcfQFp/h6+B0hgMbufHXvXOSF3Rj7Es1XXrusOhTGPEVv3qC4AaSHSjVVk8C18gFm5hpUjwN7O5u/lSov7WV5iSJMcFnSOxIv9+CboMQehtBpIvczEmRDE+r4/hq5mlpnc/ba9" - ], - "priority": ["100"] - } - }, - { - "id": "6fb96b2c-f93d-47c0-bd8b-5e9568f71a43", - "name": "hmac-generated", - "providerId": "hmac-generated", - "subComponents": {}, - "config": { - "kid": ["14d96e27-1ef3-4c48-bbc3-95968353669f"], - "secret": [ - "HjQ76-HhFsIZkDoEkmVxlVYCoXwFysEsmhK3LsyyMaI9FVyuc0Tb4kYuP2f5Pz--NYxPcvJr3Q8M8OwN9kHSTw" - ], - "priority": ["100"], - "algorithm": ["HS256"] - } - }, - { - "id": "6eba3413-03c0-4359-8b90-471f2628550e", - "name": "aes-generated", - "providerId": "aes-generated", - "subComponents": {}, - "config": { - "kid": ["e99ccacc-1cf8-42e3-ac6a-23553f29efd6"], - "secret": ["FpK8RqyaSzxuyi83SsilRA"], - "priority": ["100"] - } - } - ] + "protocolMappers" : [ { + "id" : "78f6f58f-4cd6-4635-ba1c-e8f7c9ec29f1", + "name" : "role list", + "protocol" : "saml", + "protocolMapper" : "saml-role-list-mapper", + "consentRequired" : false, + "config" : { + "single" : "false", + "attribute.nameformat" : "Basic", + "attribute.name" : "Role" + } + } ] + }, { + "id" : "93d7c992-6651-4300-9b85-832e8929bce4", + "name" : "roles", + "description" : "OpenID Connect scope for add user roles to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${rolesScopeConsentText}" }, - "internationalizationEnabled": false, - "supportedLocales": [], - "authenticationFlows": [ - { - "id": "de99607a-398d-4e10-9ced-fe7df827d7e1", - "alias": "Account verification options", - "description": "Method with which to verity the existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-email-verification", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 20, - "flowAlias": "Verify Existing Account by Re-authentication", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "37f9788d-fab3-4cbf-b16d-b6753cf4669b", - "alias": "Authentication Options", - "description": "Authentication options.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "basic-auth", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "basic-auth-otp", - "requirement": "DISABLED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-spnego", - "requirement": "DISABLED", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "c99d9202-c15e-4b14-8d7b-d4e6c28a4bd9", - "alias": "Browser - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-otp-form", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "b9c16a89-3ee9-41de-9994-e64fd2e94b20", - "alias": "Direct Grant - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "direct-grant-validate-otp", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "6c3951a3-fbe6-4412-a1c3-e00fb33a4b7c", - "alias": "First broker login - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-otp-form", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "e89e9389-cf60-42d1-8f9f-50d06ef69746", - "alias": "Handle Existing Account", - "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-confirm-link", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "Account verification options", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "b4341d72-fbd4-4aa1-bdd1-8211a4344013", - "alias": "Reset - Conditional OTP", - "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-otp", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "ce5ce09a-8423-4730-8de9-978c0981e372", - "alias": "User creation or linking", - "description": "Flow for the existing/non-existing user alternatives", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "create unique user config", - "authenticator": "idp-create-user-if-unique", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 20, - "flowAlias": "Handle Existing Account", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "64cda71a-eb38-4f52-bc85-1a1293034f45", - "alias": "Verify Existing Account by Re-authentication", - "description": "Reauthentication of existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-username-password-form", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 20, - "flowAlias": "First broker login - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "e4a47fd1-469c-4774-b010-66acfe1563b9", - "alias": "browser", - "description": "browser based authentication", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-spnego", - "requirement": "DISABLED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "identity-provider-redirector", - "requirement": "ALTERNATIVE", - "priority": 25, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 30, - "flowAlias": "forms", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "7fd482ae-b75b-4d86-88c9-8a6847c54b77", - "alias": "clients", - "description": "Base authentication for clients", - "providerId": "client-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "client-secret", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-jwt", - "requirement": "ALTERNATIVE", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-secret-jwt", - "requirement": "ALTERNATIVE", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-x509", - "requirement": "ALTERNATIVE", - "priority": 40, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "1fe97e87-f958-46ab-abe3-5e8765a67384", - "alias": "direct grant", - "description": "OpenID Connect Resource Owner Grant", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "direct-grant-validate-username", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "direct-grant-validate-password", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 30, - "flowAlias": "Direct Grant - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "0496f821-25c0-4537-93d5-821554974d09", - "alias": "docker auth", - "description": "Used by Docker clients to authenticate against the IDP", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "docker-http-basic-authenticator", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "7f4f7c4f-b832-4199-b7f0-ccd1fc8c1c44", - "alias": "first broker login", - "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "review profile config", - "authenticator": "idp-review-profile", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "User creation or linking", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "8a74e09d-1033-4740-b408-f5c9ded1b94d", - "alias": "forms", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 20, - "flowAlias": "Browser - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "88df51d9-ff69-4871-8f7d-ef57a9d40be8", - "alias": "http challenge", - "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "no-cookie-redirect", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "Authentication Options", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "cbc9b825-ce60-4ed7-8f8d-7ed0b002c63d", - "alias": "registration", - "description": "registration flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-page-form", - "requirement": "REQUIRED", - "priority": 10, - "flowAlias": "registration form", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "0a2f422d-623a-49a0-93d1-6ef8ce59172e", - "alias": "registration form", - "description": "registration form", - "providerId": "form-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-user-creation", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-profile-action", - "requirement": "REQUIRED", - "priority": 40, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-password-action", - "requirement": "REQUIRED", - "priority": 50, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-recaptcha-action", - "requirement": "DISABLED", - "priority": 60, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "id": "cb16f7b6-ef0b-4f72-a1a9-126e18c0dbf8", - "alias": "reset credentials", - "description": "Reset credentials for a user if they forgot their password or something", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "reset-credentials-choose-user", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-credential-email", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-password", - "requirement": "REQUIRED", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 40, - "flowAlias": "Reset - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "id": "08085618-ea36-4186-b390-2724a3ca2a0c", - "alias": "saml ecp", - "description": "SAML ECP Profile Authentication Flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "http-basic-authenticator", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] + "protocolMappers" : [ { + "id" : "e7134f4a-d194-4dfc-bf3e-40de3b3bf087", + "name" : "client roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-client-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "resource_access.${client_id}.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + }, { + "id" : "936536a3-9b53-44a5-ad90-1e82871c93e8", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + }, { + "id" : "275e146a-73cc-4930-a752-2bd18764f65b", + "name" : "realm roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "realm_access.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + } ] + }, { + "id" : "17745df8-9dda-4ded-b2e1-10c10892a341", + "name" : "web-origins", + "description" : "OpenID Connect scope for add allowed web origins to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false", + "consent.screen.text" : "" + }, + "protocolMappers" : [ { + "id" : "15a58940-f2e8-4032-bdf0-f5d586fa86e1", + "name" : "allowed web origins", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-allowed-origins-mapper", + "consentRequired" : false, + "config" : { } + } ] + } ], + "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins" ], + "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], + "browserSecurityHeaders" : { + "contentSecurityPolicyReportOnly" : "", + "xContentTypeOptions" : "nosniff", + "xRobotsTag" : "none", + "xFrameOptions" : "SAMEORIGIN", + "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection" : "1; mode=block", + "strictTransportSecurity" : "max-age=31536000; includeSubDomains" + }, + "smtpServer" : { }, + "eventsEnabled" : false, + "eventsListeners" : [ "jboss-logging" ], + "enabledEventTypes" : [ ], + "adminEventsEnabled" : false, + "adminEventsDetailsEnabled" : false, + "components" : { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { + "id" : "0c68916d-29e5-4bc3-8f34-66d3e074d0ba", + "name" : "Trusted Hosts", + "providerId" : "trusted-hosts", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "host-sending-registration-request-must-match" : [ "true" ], + "client-uris-must-match" : [ "true" ] + } + }, { + "id" : "01017e9d-ccee-4682-a43d-dedc779456df", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "oidc-address-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper" ] + } + }, { + "id" : "04a61e10-fc46-4e0c-ad0a-3eec163d6e24", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "aae5ad0e-f00b-4ebf-8b7f-dac3e020d5d5", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] } - ], - "authenticatorConfig": [ - { - "id": "878df3e1-ea29-4651-a968-b1112bb9c0b3", - "alias": "create unique user config", - "config": { - "require.password.update.after.registration": "false" - } - }, - { - "id": "2dd45740-0f70-4364-9df4-71ecfb294bc7", - "alias": "review profile config", - "config": { - "update.profile.on.first.login": "missing" - } + }, { + "id" : "cae2d534-9164-4760-a10d-19cfdf36ac0d", + "name" : "Consent Required", + "providerId" : "consent-required", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "cb3d8398-9fda-423a-bc1a-9504ead9d10f", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "oidc-address-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper" ] } - ], - "requiredActions": [ - { - "alias": "CONFIGURE_TOTP", - "name": "Configure OTP", - "providerId": "CONFIGURE_TOTP", - "enabled": true, - "defaultAction": false, - "priority": 10, - "config": {} - }, - { - "alias": "terms_and_conditions", - "name": "Terms and Conditions", - "providerId": "terms_and_conditions", - "enabled": false, - "defaultAction": false, - "priority": 20, - "config": {} - }, - { - "alias": "UPDATE_PASSWORD", - "name": "Update Password", - "providerId": "UPDATE_PASSWORD", - "enabled": true, - "defaultAction": false, - "priority": 30, - "config": {} - }, - { - "alias": "UPDATE_PROFILE", - "name": "Update Profile", - "providerId": "UPDATE_PROFILE", - "enabled": true, - "defaultAction": false, - "priority": 40, - "config": {} - }, - { - "alias": "VERIFY_EMAIL", - "name": "Verify Email", - "providerId": "VERIFY_EMAIL", - "enabled": true, - "defaultAction": false, - "priority": 50, - "config": {} - }, - { - "alias": "update_user_locale", - "name": "Update User Locale", - "providerId": "update_user_locale", - "enabled": true, - "defaultAction": false, - "priority": 1000, - "config": {} + }, { + "id" : "65750361-be2e-41cc-ae63-1abdda285720", + "name" : "Full Scope Disabled", + "providerId" : "scope", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "f1669b22-e29d-4eda-97b3-f4ab3ec57673", + "name" : "Max Clients Limit", + "providerId" : "max-clients", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "max-clients" : [ "200" ] } - ], - "browserFlow": "browser", - "registrationFlow": "registration", - "directGrantFlow": "direct grant", - "resetCredentialsFlow": "reset credentials", - "clientAuthenticationFlow": "clients", - "dockerAuthenticationFlow": "docker auth", - "attributes": {}, - "keycloakVersion": "10.0.1", - "userManagedAccessAllowed": false - } -] + } ], + "org.keycloak.keys.KeyProvider" : [ { + "id" : "ffd05084-ba7b-4d5f-bec3-9a2c9cabb7eb", + "name" : "rsa-generated", + "providerId" : "rsa-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEowIBAAKCAQEAmKi1AOCgn2rmzdroi54yETw7vo7sVFoQsKYf3L68TgLMjHxsjZLj7o6aVi9wv3rZ0krm7JBD3nxcGSPyEkBGYwaGzO0qxSnjkEfqRFxOODjWcQglcfycY3JyQE8Uz2bJ45BQ3lebviP6AEF3RW0xmuiQ6BKr26sK8iRa7ivZH2SbLsKMVlR/7kCHNGnCnCqWXuCmMMItGT0eqclbnT0BBKBN6aTou7Nh95Uj3Iwh9EfPUIQjnrRK9YOCdh3mjvuadojiPWUvRkVOOGg9yBU32J/WKJUbO4qp5VsJGkSwBPgN5rUa0np1vP2WwDihPIFbQMUqm+ZBOOCbhbYMx4KzXwIDAQABAoIBAQCOdNi74eJiAZsyPHbHWy+zn7bM44isSoPKpKuVDjSgw8Hn03BlSM8E3fQuOwUG2niL4jPOS+3Zn8k9+Ko719kXLY77itJfvPBLwqBdfJnNo1SRlB2FWksCDlmJo4Jy7KO3hQPCCJUggWgZdv37PqOMwDwBJPNVAS8suTpViXuK66EcDsB2m4R+rRXqVXz2w20CSCT1zathxEIQsBBxKR4lJHBgCE6GDwGf7SZGIxLgQhnnYUib3rSh3RyHoexPwPUjhQmJIVPWK1krRwpW93QXGL0wWXAvROyM0kg3Qd8evqqfkPHtO+zqnYa632l93DhgeESykTImxTykPcHxz2kJAoGBAM2EXe4CiLGLbjmYxatXOpQmI8rmsskvL6FE5tjYHh2XF57U3OoNhdHyU0H7Qz8z3mRj2irwRHG6QhjH+yU1TSWgiaPLc9rZ7PE4tRCOkG1c9vjMdvexOrXs16FUY1xWXMwVhvKlZOO3t1D7yzQvzibYyrkvHST61u+bf+fbtIW1AoGBAL4ocN1HPhcro9DTyiK/AJgntnwNA4jv6QwzvK+hPqd+DJoVHhBghbHhhdpZKiPRC/2nCLuiBSNmXzG2Awapn6JoBely319InxvKrPZOFcvOxKVHw1XDtrkUaL6yM6EgvLsHU8yR8Ov6gaLdg2NbpfA+VXL1KxvVDois8s/+hgFDAoGAWMiOK3w8wTaS757oBhUw4T94xvbS1cbktK6na5YxrGbRdXRP22zsGr6s6Rw6+NrXgFcCsPoLF3Z3h20dOf3EzjSEQZZq/miWy77LudNc4WH/74uk+Ww/CMjAfpmOMx28CQ5jtf9tjlKXhwy/xFPCo1WUflu0I32ZzPlIUEnBuuECgYAhqeMhKUWSsIUVqQi10f528UDbASrJCT/GizoyFWeUGzp75JUn7Q5+CSC7IOHW6WEoDHP9U5d5Rtw/Xqt2eHzsMWIqi82DfsW8E8s+51/wbrBdWjD4c+dbKIPKjp2ZPsRqj8eEBaoS/IwKmxBxfH4J498YtNJm4Pbrt0JdFAABJQKBgHFqGu3a5cCbcuo0wDPyvibq1BcFVQSgXHBhztu8LPRHTo3BzACrVEN3MX3H+uzafb0YHDb/tpPYw/RWC8luE1CrKSr9l/qG6r3nNaTnG7rxj2dRffsyXuUwSvc+DCu1aTR2Zk/uMoH1pr66cM5YjwVzQcuPA820+1BXyFsyGDy9" ], + "certificate" : [ "MIICmTCCAYECBgFyVvIFkjANBgkqhkiG9w0BAQsFADAQMQ4wDAYDVQQDDAV0aW1lZDAeFw0yMDA1MjcxNjIxNDFaFw0zMDA1MjcxNjIzMjFaMBAxDjAMBgNVBAMMBXRpbWVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmKi1AOCgn2rmzdroi54yETw7vo7sVFoQsKYf3L68TgLMjHxsjZLj7o6aVi9wv3rZ0krm7JBD3nxcGSPyEkBGYwaGzO0qxSnjkEfqRFxOODjWcQglcfycY3JyQE8Uz2bJ45BQ3lebviP6AEF3RW0xmuiQ6BKr26sK8iRa7ivZH2SbLsKMVlR/7kCHNGnCnCqWXuCmMMItGT0eqclbnT0BBKBN6aTou7Nh95Uj3Iwh9EfPUIQjnrRK9YOCdh3mjvuadojiPWUvRkVOOGg9yBU32J/WKJUbO4qp5VsJGkSwBPgN5rUa0np1vP2WwDihPIFbQMUqm+ZBOOCbhbYMx4KzXwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQA/52H5X2MAH6aw445RbeU6fVYnKJu4hBkcIGHPjKR++Gq54M6bcbnNKNunhVbGZUqdPHX4+ktnQSZauq2hd9HRcezhL3OlmtEnLNW8BoFPaB11gmdjOiVQGm6iJsSJGxrrO7q3YzY+IB/ZTZlWmuOGnUDprFxDv0LR3M8X0ls0ygmw4/CmXZ6Fhg43Ey27ZArlSmzohAq8YGpV8HEcfQFp/h6+B0hgMbufHXvXOSF3Rj7Es1XXrusOhTGPEVv3qC4AaSHSjVVk8C18gFm5hpUjwN7O5u/lSov7WV5iSJMcFnSOxIv9+CboMQehtBpIvczEmRDE+r4/hq5mlpnc/ba9" ], + "priority" : [ "100" ] + } + }, { + "id" : "6fb96b2c-f93d-47c0-bd8b-5e9568f71a43", + "name" : "hmac-generated", + "providerId" : "hmac-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "14d96e27-1ef3-4c48-bbc3-95968353669f" ], + "secret" : [ "HjQ76-HhFsIZkDoEkmVxlVYCoXwFysEsmhK3LsyyMaI9FVyuc0Tb4kYuP2f5Pz--NYxPcvJr3Q8M8OwN9kHSTw" ], + "priority" : [ "100" ], + "algorithm" : [ "HS256" ] + } + }, { + "id" : "6eba3413-03c0-4359-8b90-471f2628550e", + "name" : "aes-generated", + "providerId" : "aes-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "e99ccacc-1cf8-42e3-ac6a-23553f29efd6" ], + "secret" : [ "FpK8RqyaSzxuyi83SsilRA" ], + "priority" : [ "100" ] + } + } ] + }, + "internationalizationEnabled" : false, + "supportedLocales" : [ ], + "authenticationFlows" : [ { + "id" : "390ed51c-103a-4fce-a941-f69b92d1790e", + "alias" : "Account verification options", + "description" : "Method with which to verity the existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-email-verification", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "ALTERNATIVE", + "priority" : 20, + "flowAlias" : "Verify Existing Account by Re-authentication", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "8b10fa7e-6afd-4545-b526-65b2ddb2c558", + "alias" : "Authentication Options", + "description" : "Authentication options.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "basic-auth", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "basic-auth-otp", + "requirement" : "DISABLED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-spnego", + "requirement" : "DISABLED", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "5d151955-5ff5-4117-bcb3-39f96fdb2f2b", + "alias" : "Browser - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-otp-form", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "94f94d53-1bad-45ba-9b01-04f25c4d4ccc", + "alias" : "Direct Grant - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "direct-grant-validate-otp", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "044e6583-5533-48bc-9877-069ae932e1a6", + "alias" : "First broker login - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-otp-form", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "0813aa63-05e7-4140-9f91-f4fe582ac66b", + "alias" : "Handle Existing Account", + "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-confirm-link", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "Account verification options", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "f057363d-fdce-4a82-8638-c7100db2c443", + "alias" : "Reset - Conditional OTP", + "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-otp", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "b55c6e7f-da4f-4e06-bed6-c14e411c309a", + "alias" : "User creation or linking", + "description" : "Flow for the existing/non-existing user alternatives", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "create unique user config", + "authenticator" : "idp-create-user-if-unique", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "ALTERNATIVE", + "priority" : 20, + "flowAlias" : "Handle Existing Account", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "65ea3b1f-9abd-403d-9465-ea7b2c83bf9e", + "alias" : "Verify Existing Account by Re-authentication", + "description" : "Reauthentication of existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-username-password-form", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 20, + "flowAlias" : "First broker login - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "f0ac75d5-5bd5-4eb4-8c10-de3422d6f652", + "alias" : "browser", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-cookie", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-spnego", + "requirement" : "DISABLED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "identity-provider-redirector", + "requirement" : "ALTERNATIVE", + "priority" : 25, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "ALTERNATIVE", + "priority" : 30, + "flowAlias" : "forms", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "96919640-961f-4763-b23f-1bb394af0498", + "alias" : "clients", + "description" : "Base authentication for clients", + "providerId" : "client-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "client-secret", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-jwt", + "requirement" : "ALTERNATIVE", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-secret-jwt", + "requirement" : "ALTERNATIVE", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-x509", + "requirement" : "ALTERNATIVE", + "priority" : 40, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "34aabf1e-e748-4d2a-8e1b-4e94f77e38c7", + "alias" : "direct grant", + "description" : "OpenID Connect Resource Owner Grant", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "direct-grant-validate-username", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "direct-grant-validate-password", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 30, + "flowAlias" : "Direct Grant - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "5406b7a7-74ac-4c25-ac9d-7b165ef78f23", + "alias" : "docker auth", + "description" : "Used by Docker clients to authenticate against the IDP", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "docker-http-basic-authenticator", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "b73d9942-db8b-4d0f-abd6-643df35e7a80", + "alias" : "first broker login", + "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "review profile config", + "authenticator" : "idp-review-profile", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "User creation or linking", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "135a219b-9ced-401f-b291-6e43212497c3", + "alias" : "forms", + "description" : "Username, password, otp and other auth forms.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-username-password-form", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 20, + "flowAlias" : "Browser - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "401ae880-d88f-4f76-9469-b5b91c69a15f", + "alias" : "http challenge", + "description" : "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "no-cookie-redirect", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "Authentication Options", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "9c3ff9e5-917a-4f23-8604-f397139bc215", + "alias" : "registration", + "description" : "registration flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-page-form", + "requirement" : "REQUIRED", + "priority" : 10, + "flowAlias" : "registration form", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "4751e2c7-5dde-4131-8301-a1cd488f4215", + "alias" : "registration form", + "description" : "registration form", + "providerId" : "form-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-user-creation", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "registration-profile-action", + "requirement" : "REQUIRED", + "priority" : 40, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "registration-password-action", + "requirement" : "REQUIRED", + "priority" : 50, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "registration-recaptcha-action", + "requirement" : "DISABLED", + "priority" : 60, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "a555a927-b641-470f-b7fe-7251f37bef12", + "alias" : "reset credentials", + "description" : "Reset credentials for a user if they forgot their password or something", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "reset-credentials-choose-user", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-credential-email", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-password", + "requirement" : "REQUIRED", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 40, + "flowAlias" : "Reset - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "b1deb8b2-fb9c-40df-820d-1bd6ba3beb2e", + "alias" : "saml ecp", + "description" : "SAML ECP Profile Authentication Flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "http-basic-authenticator", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + } ], + "authenticatorConfig" : [ { + "id" : "908ae980-a792-4181-b04c-3771fb739803", + "alias" : "create unique user config", + "config" : { + "require.password.update.after.registration" : "false" + } + }, { + "id" : "e4ce89ca-1eb6-4234-803d-0a22359983bb", + "alias" : "review profile config", + "config" : { + "update.profile.on.first.login" : "missing" + } + } ], + "requiredActions" : [ { + "alias" : "CONFIGURE_TOTP", + "name" : "Configure OTP", + "providerId" : "CONFIGURE_TOTP", + "enabled" : true, + "defaultAction" : false, + "priority" : 10, + "config" : { } + }, { + "alias" : "terms_and_conditions", + "name" : "Terms and Conditions", + "providerId" : "terms_and_conditions", + "enabled" : false, + "defaultAction" : false, + "priority" : 20, + "config" : { } + }, { + "alias" : "UPDATE_PASSWORD", + "name" : "Update Password", + "providerId" : "UPDATE_PASSWORD", + "enabled" : true, + "defaultAction" : false, + "priority" : 30, + "config" : { } + }, { + "alias" : "UPDATE_PROFILE", + "name" : "Update Profile", + "providerId" : "UPDATE_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 40, + "config" : { } + }, { + "alias" : "VERIFY_EMAIL", + "name" : "Verify Email", + "providerId" : "VERIFY_EMAIL", + "enabled" : true, + "defaultAction" : false, + "priority" : 50, + "config" : { } + }, { + "alias" : "update_user_locale", + "name" : "Update User Locale", + "providerId" : "update_user_locale", + "enabled" : true, + "defaultAction" : false, + "priority" : 1000, + "config" : { } + } ], + "browserFlow" : "browser", + "registrationFlow" : "registration", + "directGrantFlow" : "direct grant", + "resetCredentialsFlow" : "reset credentials", + "clientAuthenticationFlow" : "clients", + "dockerAuthenticationFlow" : "docker auth", + "attributes" : { + "clientSessionIdleTimeout" : "0", + "clientSessionMaxLifespan" : "0" + }, + "keycloakVersion" : "10.0.1", + "userManagedAccessAllowed" : false +} \ No newline at end of file From f500a8c68c13f1961b49df3b725b8788e833719a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 30 Nov 2020 00:15:08 +0000 Subject: [PATCH 758/980] chore(deps): bump django-money from 1.1 to 1.2.1 Bumps [django-money](https://github.com/django-money/django-money) from 1.1 to 1.2.1. - [Release notes](https://github.com/django-money/django-money/releases) - [Changelog](https://github.com/django-money/django-money/blob/master/docs/changes.rst) - [Commits](https://github.com/django-money/django-money/compare/1.1...1.2.1) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bd57aff54..d6fdfba04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,6 @@ pyexcel-ods3==0.6.0 pyexcel-xlsx==0.6.0 pyexcel-ezodf==0.3.4 django-environ==0.4.5 -django-money==1.1 +django-money==1.2.1 python-redmine==2.3.0 uwsgi==2.0.19.1 From f913a89b2c026ed9dca30b9eb547c609d0412008 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 30 Nov 2020 00:15:29 +0000 Subject: [PATCH 759/980] chore(deps-dev): bump flake8-debugger from 3.2.1 to 4.0.0 Bumps [flake8-debugger](https://github.com/jbkahn/flake8-debugger) from 3.2.1 to 4.0.0. - [Release notes](https://github.com/jbkahn/flake8-debugger/releases) - [Commits](https://github.com/jbkahn/flake8-debugger/compare/3.2.1...4.0.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d2afecd5b..fd0a4ffdb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,7 +4,7 @@ coverage==5.3 factory-boy==3.1.0 flake8==3.8.4 flake8-blind-except==0.1.1 -flake8-debugger==3.2.1 +flake8-debugger==4.0.0 flake8-deprecated==1.3 flake8-docstrings==1.5.0 flake8-isort==4.0.0 From 7266c346236e9e0d1c83d9f84b99a4e782256ba4 Mon Sep 17 00:00:00 2001 From: Yelinz Date: Tue, 1 Dec 2020 14:19:47 +0100 Subject: [PATCH 760/980] feat(projects): add currency fields to task and project Adds amount_offered and amount_invoiced currency fields to task and project. --- .../migrations/0009_auto_20201201_1412.py | 870 ++++++++++++++++++ timed/projects/models.py | 13 + 2 files changed, 883 insertions(+) create mode 100644 timed/projects/migrations/0009_auto_20201201_1412.py diff --git a/timed/projects/migrations/0009_auto_20201201_1412.py b/timed/projects/migrations/0009_auto_20201201_1412.py new file mode 100644 index 000000000..274b72849 --- /dev/null +++ b/timed/projects/migrations/0009_auto_20201201_1412.py @@ -0,0 +1,870 @@ +# Generated by Django 2.2.15 on 2020-12-01 13:12 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0008_auto_20190220_1133"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="amount_invoiced", + field=djmoney.models.fields.MoneyField( + blank=True, + decimal_places=2, + default_currency="CHF", + max_digits=10, + null=True, + ), + ), + migrations.AddField( + model_name="project", + name="amount_invoiced_currency", + field=djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghani"), + ("DZD", "Algerian Dinar"), + ("ARS", "Argentine Peso"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Guilder"), + ("AUD", "Australian Dollar"), + ("AZN", "Azerbaijanian Manat"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("THB", "Baht"), + ("PAB", "Balboa"), + ("BBD", "Barbados Dollar"), + ("BYN", "Belarussian Ruble"), + ("BYR", "Belarussian Ruble"), + ("BZD", "Belize Dollar"), + ("BMD", "Bermudian Dollar (customarily known as Bermuda Dollar)"), + ("BTN", "Bhutanese ngultrum"), + ("VEF", "Bolivar Fuerte"), + ("BOB", "Boliviano"), + ("XBA", "Bond Markets Units European Composite Unit (EURCO)"), + ("BRL", "Brazilian Real"), + ("BND", "Brunei Dollar"), + ("BGN", "Bulgarian Lev"), + ("BIF", "Burundi Franc"), + ("XOF", "CFA Franc BCEAO"), + ("XAF", "CFA franc BEAC"), + ("XPF", "CFP Franc"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verde Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("CLP", "Chilean peso"), + ("XTS", "Codes specifically reserved for testing purposes"), + ("COP", "Colombian peso"), + ("KMF", "Comoro Franc"), + ("CDF", "Congolese franc"), + ("BAM", "Convertible Marks"), + ("NIO", "Cordoba Oro"), + ("CRC", "Costa Rican Colon"), + ("HRK", "Croatian Kuna"), + ("CUP", "Cuban Peso"), + ("CUC", "Cuban convertible peso"), + ("CZK", "Czech Koruna"), + ("GMD", "Dalasi"), + ("DKK", "Danish Krone"), + ("MKD", "Denar"), + ("DJF", "Djibouti Franc"), + ("STD", "Dobra"), + ("DOP", "Dominican Peso"), + ("VND", "Dong"), + ("XCD", "East Caribbean Dollar"), + ("EGP", "Egyptian Pound"), + ("SVC", "El Salvador Colon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBB", "European Monetary Unit (E.M.U.-6)"), + ("XBD", "European Unit of Account 17(E.U.A.-17)"), + ("XBC", "European Unit of Account 9(E.U.A.-9)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fiji Dollar"), + ("HUF", "Forint"), + ("GHS", "Ghana Cedi"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("XFO", "Gold-Franc"), + ("PYG", "Guarani"), + ("GNF", "Guinea Franc"), + ("GYD", "Guyana Dollar"), + ("HTG", "Haitian gourde"), + ("HKD", "Hong Kong Dollar"), + ("UAH", "Hryvnia"), + ("ISK", "Iceland Krona"), + ("INR", "Indian Rupee"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IMP", "Isle of Man Pound"), + ("JMD", "Jamaican Dollar"), + ("JOD", "Jordanian Dinar"), + ("KES", "Kenyan Shilling"), + ("PGK", "Kina"), + ("LAK", "Kip"), + ("KWD", "Kuwaiti Dinar"), + ("AOA", "Kwanza"), + ("MMK", "Kyat"), + ("GEL", "Lari"), + ("LVL", "Latvian Lats"), + ("LBP", "Lebanese Pound"), + ("ALL", "Lek"), + ("HNL", "Lempira"), + ("SLL", "Leone"), + ("LSL", "Lesotho loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("SZL", "Lilangeni"), + ("LTL", "Lithuanian Litas"), + ("MGA", "Malagasy Ariary"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("TMM", "Manat"), + ("MUR", "Mauritius Rupee"), + ("MZN", "Metical"), + ("MXV", "Mexican Unidad de Inversion (UDI)"), + ("MXN", "Mexican peso"), + ("MDL", "Moldovan Leu"), + ("MAD", "Moroccan Dirham"), + ("BOV", "Mvdol"), + ("NGN", "Naira"), + ("ERN", "Nakfa"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillian Guilder"), + ("ILS", "New Israeli Sheqel"), + ("RON", "New Leu"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("PEN", "Nuevo Sol"), + ("MRO", "Ouguiya"), + ("TOP", "Paanga"), + ("PKR", "Pakistan Rupee"), + ("XPD", "Palladium"), + ("MOP", "Pataca"), + ("PHP", "Philippine Peso"), + ("XPT", "Platinum"), + ("GBP", "Pound Sterling"), + ("BWP", "Pula"), + ("QAR", "Qatari Rial"), + ("GTQ", "Quetzal"), + ("ZAR", "Rand"), + ("OMR", "Rial Omani"), + ("KHR", "Riel"), + ("MVR", "Rufiyaa"), + ("IDR", "Rupiah"), + ("RUB", "Russian Ruble"), + ("RWF", "Rwanda Franc"), + ("XDR", "SDR"), + ("SHP", "Saint Helena Pound"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("SCR", "Seychelles Rupee"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SBD", "Solomon Islands Dollar"), + ("KGS", "Som"), + ("SOS", "Somali Shilling"), + ("TJS", "Somoni"), + ("SSP", "South Sudanese Pound"), + ("LKR", "Sri Lanka Rupee"), + ("XSU", "Sucre"), + ("SDG", "Sudanese Pound"), + ("SRD", "Surinam Dollar"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("BDT", "Taka"), + ("WST", "Tala"), + ("TZS", "Tanzanian Shilling"), + ("KZT", "Tenge"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TTD", "Trinidad and Tobago Dollar"), + ("MNT", "Tugrik"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TMT", "Turkmenistan New Manat"), + ("TVD", "Tuvalu dollar"), + ("AED", "UAE Dirham"), + ("XFU", "UIC-Franc"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("UGX", "Uganda Shilling"), + ("CLF", "Unidad de Fomento"), + ("COU", "Unidad de Valor Real"), + ("UYI", "Uruguay Peso en Unidades Indexadas (URUIURUI)"), + ("UYU", "Uruguayan peso"), + ("UZS", "Uzbekistan Sum"), + ("VUV", "Vatu"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("KRW", "Won"), + ("YER", "Yemeni Rial"), + ("JPY", "Yen"), + ("CNY", "Yuan Renminbi"), + ("ZMK", "Zambian Kwacha"), + ("ZMW", "Zambian Kwacha"), + ("ZWD", "Zimbabwe Dollar A/06"), + ("ZWN", "Zimbabwe dollar A/08"), + ("ZWL", "Zimbabwe dollar A/09"), + ("PLN", "Zloty"), + ], + default="CHF", + editable=False, + max_length=3, + ), + ), + migrations.AddField( + model_name="project", + name="amount_offered", + field=djmoney.models.fields.MoneyField( + blank=True, + decimal_places=2, + default_currency="CHF", + max_digits=10, + null=True, + ), + ), + migrations.AddField( + model_name="project", + name="amount_offered_currency", + field=djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghani"), + ("DZD", "Algerian Dinar"), + ("ARS", "Argentine Peso"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Guilder"), + ("AUD", "Australian Dollar"), + ("AZN", "Azerbaijanian Manat"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("THB", "Baht"), + ("PAB", "Balboa"), + ("BBD", "Barbados Dollar"), + ("BYN", "Belarussian Ruble"), + ("BYR", "Belarussian Ruble"), + ("BZD", "Belize Dollar"), + ("BMD", "Bermudian Dollar (customarily known as Bermuda Dollar)"), + ("BTN", "Bhutanese ngultrum"), + ("VEF", "Bolivar Fuerte"), + ("BOB", "Boliviano"), + ("XBA", "Bond Markets Units European Composite Unit (EURCO)"), + ("BRL", "Brazilian Real"), + ("BND", "Brunei Dollar"), + ("BGN", "Bulgarian Lev"), + ("BIF", "Burundi Franc"), + ("XOF", "CFA Franc BCEAO"), + ("XAF", "CFA franc BEAC"), + ("XPF", "CFP Franc"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verde Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("CLP", "Chilean peso"), + ("XTS", "Codes specifically reserved for testing purposes"), + ("COP", "Colombian peso"), + ("KMF", "Comoro Franc"), + ("CDF", "Congolese franc"), + ("BAM", "Convertible Marks"), + ("NIO", "Cordoba Oro"), + ("CRC", "Costa Rican Colon"), + ("HRK", "Croatian Kuna"), + ("CUP", "Cuban Peso"), + ("CUC", "Cuban convertible peso"), + ("CZK", "Czech Koruna"), + ("GMD", "Dalasi"), + ("DKK", "Danish Krone"), + ("MKD", "Denar"), + ("DJF", "Djibouti Franc"), + ("STD", "Dobra"), + ("DOP", "Dominican Peso"), + ("VND", "Dong"), + ("XCD", "East Caribbean Dollar"), + ("EGP", "Egyptian Pound"), + ("SVC", "El Salvador Colon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBB", "European Monetary Unit (E.M.U.-6)"), + ("XBD", "European Unit of Account 17(E.U.A.-17)"), + ("XBC", "European Unit of Account 9(E.U.A.-9)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fiji Dollar"), + ("HUF", "Forint"), + ("GHS", "Ghana Cedi"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("XFO", "Gold-Franc"), + ("PYG", "Guarani"), + ("GNF", "Guinea Franc"), + ("GYD", "Guyana Dollar"), + ("HTG", "Haitian gourde"), + ("HKD", "Hong Kong Dollar"), + ("UAH", "Hryvnia"), + ("ISK", "Iceland Krona"), + ("INR", "Indian Rupee"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IMP", "Isle of Man Pound"), + ("JMD", "Jamaican Dollar"), + ("JOD", "Jordanian Dinar"), + ("KES", "Kenyan Shilling"), + ("PGK", "Kina"), + ("LAK", "Kip"), + ("KWD", "Kuwaiti Dinar"), + ("AOA", "Kwanza"), + ("MMK", "Kyat"), + ("GEL", "Lari"), + ("LVL", "Latvian Lats"), + ("LBP", "Lebanese Pound"), + ("ALL", "Lek"), + ("HNL", "Lempira"), + ("SLL", "Leone"), + ("LSL", "Lesotho loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("SZL", "Lilangeni"), + ("LTL", "Lithuanian Litas"), + ("MGA", "Malagasy Ariary"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("TMM", "Manat"), + ("MUR", "Mauritius Rupee"), + ("MZN", "Metical"), + ("MXV", "Mexican Unidad de Inversion (UDI)"), + ("MXN", "Mexican peso"), + ("MDL", "Moldovan Leu"), + ("MAD", "Moroccan Dirham"), + ("BOV", "Mvdol"), + ("NGN", "Naira"), + ("ERN", "Nakfa"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillian Guilder"), + ("ILS", "New Israeli Sheqel"), + ("RON", "New Leu"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("PEN", "Nuevo Sol"), + ("MRO", "Ouguiya"), + ("TOP", "Paanga"), + ("PKR", "Pakistan Rupee"), + ("XPD", "Palladium"), + ("MOP", "Pataca"), + ("PHP", "Philippine Peso"), + ("XPT", "Platinum"), + ("GBP", "Pound Sterling"), + ("BWP", "Pula"), + ("QAR", "Qatari Rial"), + ("GTQ", "Quetzal"), + ("ZAR", "Rand"), + ("OMR", "Rial Omani"), + ("KHR", "Riel"), + ("MVR", "Rufiyaa"), + ("IDR", "Rupiah"), + ("RUB", "Russian Ruble"), + ("RWF", "Rwanda Franc"), + ("XDR", "SDR"), + ("SHP", "Saint Helena Pound"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("SCR", "Seychelles Rupee"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SBD", "Solomon Islands Dollar"), + ("KGS", "Som"), + ("SOS", "Somali Shilling"), + ("TJS", "Somoni"), + ("SSP", "South Sudanese Pound"), + ("LKR", "Sri Lanka Rupee"), + ("XSU", "Sucre"), + ("SDG", "Sudanese Pound"), + ("SRD", "Surinam Dollar"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("BDT", "Taka"), + ("WST", "Tala"), + ("TZS", "Tanzanian Shilling"), + ("KZT", "Tenge"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TTD", "Trinidad and Tobago Dollar"), + ("MNT", "Tugrik"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TMT", "Turkmenistan New Manat"), + ("TVD", "Tuvalu dollar"), + ("AED", "UAE Dirham"), + ("XFU", "UIC-Franc"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("UGX", "Uganda Shilling"), + ("CLF", "Unidad de Fomento"), + ("COU", "Unidad de Valor Real"), + ("UYI", "Uruguay Peso en Unidades Indexadas (URUIURUI)"), + ("UYU", "Uruguayan peso"), + ("UZS", "Uzbekistan Sum"), + ("VUV", "Vatu"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("KRW", "Won"), + ("YER", "Yemeni Rial"), + ("JPY", "Yen"), + ("CNY", "Yuan Renminbi"), + ("ZMK", "Zambian Kwacha"), + ("ZMW", "Zambian Kwacha"), + ("ZWD", "Zimbabwe Dollar A/06"), + ("ZWN", "Zimbabwe dollar A/08"), + ("ZWL", "Zimbabwe dollar A/09"), + ("PLN", "Zloty"), + ], + default="CHF", + editable=False, + max_length=3, + ), + ), + migrations.AddField( + model_name="task", + name="amount_invoiced", + field=djmoney.models.fields.MoneyField( + blank=True, + decimal_places=2, + default_currency="CHF", + max_digits=10, + null=True, + ), + ), + migrations.AddField( + model_name="task", + name="amount_invoiced_currency", + field=djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghani"), + ("DZD", "Algerian Dinar"), + ("ARS", "Argentine Peso"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Guilder"), + ("AUD", "Australian Dollar"), + ("AZN", "Azerbaijanian Manat"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("THB", "Baht"), + ("PAB", "Balboa"), + ("BBD", "Barbados Dollar"), + ("BYN", "Belarussian Ruble"), + ("BYR", "Belarussian Ruble"), + ("BZD", "Belize Dollar"), + ("BMD", "Bermudian Dollar (customarily known as Bermuda Dollar)"), + ("BTN", "Bhutanese ngultrum"), + ("VEF", "Bolivar Fuerte"), + ("BOB", "Boliviano"), + ("XBA", "Bond Markets Units European Composite Unit (EURCO)"), + ("BRL", "Brazilian Real"), + ("BND", "Brunei Dollar"), + ("BGN", "Bulgarian Lev"), + ("BIF", "Burundi Franc"), + ("XOF", "CFA Franc BCEAO"), + ("XAF", "CFA franc BEAC"), + ("XPF", "CFP Franc"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verde Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("CLP", "Chilean peso"), + ("XTS", "Codes specifically reserved for testing purposes"), + ("COP", "Colombian peso"), + ("KMF", "Comoro Franc"), + ("CDF", "Congolese franc"), + ("BAM", "Convertible Marks"), + ("NIO", "Cordoba Oro"), + ("CRC", "Costa Rican Colon"), + ("HRK", "Croatian Kuna"), + ("CUP", "Cuban Peso"), + ("CUC", "Cuban convertible peso"), + ("CZK", "Czech Koruna"), + ("GMD", "Dalasi"), + ("DKK", "Danish Krone"), + ("MKD", "Denar"), + ("DJF", "Djibouti Franc"), + ("STD", "Dobra"), + ("DOP", "Dominican Peso"), + ("VND", "Dong"), + ("XCD", "East Caribbean Dollar"), + ("EGP", "Egyptian Pound"), + ("SVC", "El Salvador Colon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBB", "European Monetary Unit (E.M.U.-6)"), + ("XBD", "European Unit of Account 17(E.U.A.-17)"), + ("XBC", "European Unit of Account 9(E.U.A.-9)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fiji Dollar"), + ("HUF", "Forint"), + ("GHS", "Ghana Cedi"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("XFO", "Gold-Franc"), + ("PYG", "Guarani"), + ("GNF", "Guinea Franc"), + ("GYD", "Guyana Dollar"), + ("HTG", "Haitian gourde"), + ("HKD", "Hong Kong Dollar"), + ("UAH", "Hryvnia"), + ("ISK", "Iceland Krona"), + ("INR", "Indian Rupee"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IMP", "Isle of Man Pound"), + ("JMD", "Jamaican Dollar"), + ("JOD", "Jordanian Dinar"), + ("KES", "Kenyan Shilling"), + ("PGK", "Kina"), + ("LAK", "Kip"), + ("KWD", "Kuwaiti Dinar"), + ("AOA", "Kwanza"), + ("MMK", "Kyat"), + ("GEL", "Lari"), + ("LVL", "Latvian Lats"), + ("LBP", "Lebanese Pound"), + ("ALL", "Lek"), + ("HNL", "Lempira"), + ("SLL", "Leone"), + ("LSL", "Lesotho loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("SZL", "Lilangeni"), + ("LTL", "Lithuanian Litas"), + ("MGA", "Malagasy Ariary"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("TMM", "Manat"), + ("MUR", "Mauritius Rupee"), + ("MZN", "Metical"), + ("MXV", "Mexican Unidad de Inversion (UDI)"), + ("MXN", "Mexican peso"), + ("MDL", "Moldovan Leu"), + ("MAD", "Moroccan Dirham"), + ("BOV", "Mvdol"), + ("NGN", "Naira"), + ("ERN", "Nakfa"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillian Guilder"), + ("ILS", "New Israeli Sheqel"), + ("RON", "New Leu"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("PEN", "Nuevo Sol"), + ("MRO", "Ouguiya"), + ("TOP", "Paanga"), + ("PKR", "Pakistan Rupee"), + ("XPD", "Palladium"), + ("MOP", "Pataca"), + ("PHP", "Philippine Peso"), + ("XPT", "Platinum"), + ("GBP", "Pound Sterling"), + ("BWP", "Pula"), + ("QAR", "Qatari Rial"), + ("GTQ", "Quetzal"), + ("ZAR", "Rand"), + ("OMR", "Rial Omani"), + ("KHR", "Riel"), + ("MVR", "Rufiyaa"), + ("IDR", "Rupiah"), + ("RUB", "Russian Ruble"), + ("RWF", "Rwanda Franc"), + ("XDR", "SDR"), + ("SHP", "Saint Helena Pound"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("SCR", "Seychelles Rupee"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SBD", "Solomon Islands Dollar"), + ("KGS", "Som"), + ("SOS", "Somali Shilling"), + ("TJS", "Somoni"), + ("SSP", "South Sudanese Pound"), + ("LKR", "Sri Lanka Rupee"), + ("XSU", "Sucre"), + ("SDG", "Sudanese Pound"), + ("SRD", "Surinam Dollar"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("BDT", "Taka"), + ("WST", "Tala"), + ("TZS", "Tanzanian Shilling"), + ("KZT", "Tenge"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TTD", "Trinidad and Tobago Dollar"), + ("MNT", "Tugrik"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TMT", "Turkmenistan New Manat"), + ("TVD", "Tuvalu dollar"), + ("AED", "UAE Dirham"), + ("XFU", "UIC-Franc"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("UGX", "Uganda Shilling"), + ("CLF", "Unidad de Fomento"), + ("COU", "Unidad de Valor Real"), + ("UYI", "Uruguay Peso en Unidades Indexadas (URUIURUI)"), + ("UYU", "Uruguayan peso"), + ("UZS", "Uzbekistan Sum"), + ("VUV", "Vatu"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("KRW", "Won"), + ("YER", "Yemeni Rial"), + ("JPY", "Yen"), + ("CNY", "Yuan Renminbi"), + ("ZMK", "Zambian Kwacha"), + ("ZMW", "Zambian Kwacha"), + ("ZWD", "Zimbabwe Dollar A/06"), + ("ZWN", "Zimbabwe dollar A/08"), + ("ZWL", "Zimbabwe dollar A/09"), + ("PLN", "Zloty"), + ], + default="CHF", + editable=False, + max_length=3, + ), + ), + migrations.AddField( + model_name="task", + name="amount_offered", + field=djmoney.models.fields.MoneyField( + blank=True, + decimal_places=2, + default_currency="CHF", + max_digits=10, + null=True, + ), + ), + migrations.AddField( + model_name="task", + name="amount_offered_currency", + field=djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghani"), + ("DZD", "Algerian Dinar"), + ("ARS", "Argentine Peso"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Guilder"), + ("AUD", "Australian Dollar"), + ("AZN", "Azerbaijanian Manat"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("THB", "Baht"), + ("PAB", "Balboa"), + ("BBD", "Barbados Dollar"), + ("BYN", "Belarussian Ruble"), + ("BYR", "Belarussian Ruble"), + ("BZD", "Belize Dollar"), + ("BMD", "Bermudian Dollar (customarily known as Bermuda Dollar)"), + ("BTN", "Bhutanese ngultrum"), + ("VEF", "Bolivar Fuerte"), + ("BOB", "Boliviano"), + ("XBA", "Bond Markets Units European Composite Unit (EURCO)"), + ("BRL", "Brazilian Real"), + ("BND", "Brunei Dollar"), + ("BGN", "Bulgarian Lev"), + ("BIF", "Burundi Franc"), + ("XOF", "CFA Franc BCEAO"), + ("XAF", "CFA franc BEAC"), + ("XPF", "CFP Franc"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verde Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("CLP", "Chilean peso"), + ("XTS", "Codes specifically reserved for testing purposes"), + ("COP", "Colombian peso"), + ("KMF", "Comoro Franc"), + ("CDF", "Congolese franc"), + ("BAM", "Convertible Marks"), + ("NIO", "Cordoba Oro"), + ("CRC", "Costa Rican Colon"), + ("HRK", "Croatian Kuna"), + ("CUP", "Cuban Peso"), + ("CUC", "Cuban convertible peso"), + ("CZK", "Czech Koruna"), + ("GMD", "Dalasi"), + ("DKK", "Danish Krone"), + ("MKD", "Denar"), + ("DJF", "Djibouti Franc"), + ("STD", "Dobra"), + ("DOP", "Dominican Peso"), + ("VND", "Dong"), + ("XCD", "East Caribbean Dollar"), + ("EGP", "Egyptian Pound"), + ("SVC", "El Salvador Colon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBB", "European Monetary Unit (E.M.U.-6)"), + ("XBD", "European Unit of Account 17(E.U.A.-17)"), + ("XBC", "European Unit of Account 9(E.U.A.-9)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fiji Dollar"), + ("HUF", "Forint"), + ("GHS", "Ghana Cedi"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("XFO", "Gold-Franc"), + ("PYG", "Guarani"), + ("GNF", "Guinea Franc"), + ("GYD", "Guyana Dollar"), + ("HTG", "Haitian gourde"), + ("HKD", "Hong Kong Dollar"), + ("UAH", "Hryvnia"), + ("ISK", "Iceland Krona"), + ("INR", "Indian Rupee"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IMP", "Isle of Man Pound"), + ("JMD", "Jamaican Dollar"), + ("JOD", "Jordanian Dinar"), + ("KES", "Kenyan Shilling"), + ("PGK", "Kina"), + ("LAK", "Kip"), + ("KWD", "Kuwaiti Dinar"), + ("AOA", "Kwanza"), + ("MMK", "Kyat"), + ("GEL", "Lari"), + ("LVL", "Latvian Lats"), + ("LBP", "Lebanese Pound"), + ("ALL", "Lek"), + ("HNL", "Lempira"), + ("SLL", "Leone"), + ("LSL", "Lesotho loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("SZL", "Lilangeni"), + ("LTL", "Lithuanian Litas"), + ("MGA", "Malagasy Ariary"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("TMM", "Manat"), + ("MUR", "Mauritius Rupee"), + ("MZN", "Metical"), + ("MXV", "Mexican Unidad de Inversion (UDI)"), + ("MXN", "Mexican peso"), + ("MDL", "Moldovan Leu"), + ("MAD", "Moroccan Dirham"), + ("BOV", "Mvdol"), + ("NGN", "Naira"), + ("ERN", "Nakfa"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillian Guilder"), + ("ILS", "New Israeli Sheqel"), + ("RON", "New Leu"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("PEN", "Nuevo Sol"), + ("MRO", "Ouguiya"), + ("TOP", "Paanga"), + ("PKR", "Pakistan Rupee"), + ("XPD", "Palladium"), + ("MOP", "Pataca"), + ("PHP", "Philippine Peso"), + ("XPT", "Platinum"), + ("GBP", "Pound Sterling"), + ("BWP", "Pula"), + ("QAR", "Qatari Rial"), + ("GTQ", "Quetzal"), + ("ZAR", "Rand"), + ("OMR", "Rial Omani"), + ("KHR", "Riel"), + ("MVR", "Rufiyaa"), + ("IDR", "Rupiah"), + ("RUB", "Russian Ruble"), + ("RWF", "Rwanda Franc"), + ("XDR", "SDR"), + ("SHP", "Saint Helena Pound"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("SCR", "Seychelles Rupee"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SBD", "Solomon Islands Dollar"), + ("KGS", "Som"), + ("SOS", "Somali Shilling"), + ("TJS", "Somoni"), + ("SSP", "South Sudanese Pound"), + ("LKR", "Sri Lanka Rupee"), + ("XSU", "Sucre"), + ("SDG", "Sudanese Pound"), + ("SRD", "Surinam Dollar"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("BDT", "Taka"), + ("WST", "Tala"), + ("TZS", "Tanzanian Shilling"), + ("KZT", "Tenge"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TTD", "Trinidad and Tobago Dollar"), + ("MNT", "Tugrik"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TMT", "Turkmenistan New Manat"), + ("TVD", "Tuvalu dollar"), + ("AED", "UAE Dirham"), + ("XFU", "UIC-Franc"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("UGX", "Uganda Shilling"), + ("CLF", "Unidad de Fomento"), + ("COU", "Unidad de Valor Real"), + ("UYI", "Uruguay Peso en Unidades Indexadas (URUIURUI)"), + ("UYU", "Uruguayan peso"), + ("UZS", "Uzbekistan Sum"), + ("VUV", "Vatu"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("KRW", "Won"), + ("YER", "Yemeni Rial"), + ("JPY", "Yen"), + ("CNY", "Yuan Renminbi"), + ("ZMK", "Zambian Kwacha"), + ("ZMW", "Zambian Kwacha"), + ("ZWD", "Zimbabwe Dollar A/06"), + ("ZWN", "Zimbabwe dollar A/08"), + ("ZWL", "Zimbabwe dollar A/09"), + ("PLN", "Zloty"), + ], + default="CHF", + editable=False, + max_length=3, + ), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 441ec661b..e052f68b5 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.db import models +from djmoney.models.fields import MoneyField class Customer(models.Model): @@ -89,6 +90,12 @@ class Project(models.Model): ) reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="reviews") customer_visible = models.BooleanField(default=False) + amount_offered = MoneyField( + max_digits=10, decimal_places=2, default_currency="CHF", blank=True, null=True + ) + amount_invoiced = MoneyField( + max_digits=10, decimal_places=2, default_currency="CHF", blank=True, null=True + ) def __str__(self): """Represent the model as a string. @@ -123,6 +130,12 @@ class Task(models.Model): null=True, related_name="tasks", ) + amount_offered = MoneyField( + max_digits=10, decimal_places=2, default_currency="CHF", blank=True, null=True + ) + amount_invoiced = MoneyField( + max_digits=10, decimal_places=2, default_currency="CHF", blank=True, null=True + ) def __str__(self): """Represent the model as a string. From fe41199527e5ab37f23c715d844805b7d8944d64 Mon Sep 17 00:00:00 2001 From: Yelinz Date: Thu, 3 Dec 2020 17:03:28 +0100 Subject: [PATCH 761/980] feat: add billed flag to project and tracking --- timed/permissions.py | 7 + timed/projects/factories.py | 1 + .../migrations/0010_project_billed.py | 18 + timed/projects/models.py | 1 + timed/projects/serializers.py | 1 + timed/tracking/factories.py | 1 + timed/tracking/filters.py | 3 +- .../tracking/migrations/0013_report_billed.py | 18 + timed/tracking/models.py | 6 + timed/tracking/serializers.py | 18 +- timed/tracking/tests/test_report.py | 454 ++++++++++++++---- timed/tracking/views.py | 14 +- 12 files changed, 432 insertions(+), 110 deletions(-) create mode 100644 timed/projects/migrations/0010_project_billed.py create mode 100644 timed/tracking/migrations/0013_report_billed.py diff --git a/timed/permissions.py b/timed/permissions.py index b806be5cb..3b1816b4e 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -134,3 +134,10 @@ class IsNotTransferred(BasePermission): def has_object_permission(self, request, view, obj): return not obj.transferred + + +class IsNotBilledAndVerfied(BasePermission): + """Allows access only to not billed and not verfied objects.""" + + def has_object_permission(self, request, view, obj): + return not (obj.billed is True and obj.verified_by_id is not None) diff --git a/timed/projects/factories.py b/timed/projects/factories.py index 1c75884c8..e751c24bd 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -43,6 +43,7 @@ class ProjectFactory(DjangoModelFactory): name = Faker("catch_phrase") estimated_time = Faker("time_delta") archived = False + billed = False comment = Faker("sentence") customer = SubFactory("timed.projects.factories.CustomerFactory") cost_center = SubFactory("timed.projects.factories.CostCenterFactory") diff --git a/timed/projects/migrations/0010_project_billed.py b/timed/projects/migrations/0010_project_billed.py new file mode 100644 index 000000000..50f17c30f --- /dev/null +++ b/timed/projects/migrations/0010_project_billed.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.15 on 2020-12-01 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0009_auto_20201201_1412"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="billed", + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index e052f68b5..a6d5ab166 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -70,6 +70,7 @@ class Project(models.Model): reference = models.CharField(max_length=255, db_index=True, blank=True, null=True) comment = models.TextField(blank=True) archived = models.BooleanField(default=False) + billed = models.BooleanField(default=False) estimated_time = models.DurationField(blank=True, null=True) customer = models.ForeignKey( "projects.Customer", on_delete=models.CASCADE, related_name="projects" diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index d109b9e77..35f895ccc 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -71,6 +71,7 @@ class Meta: "comment", "estimated_time", "archived", + "billed", "customer", "billing_type", "cost_center", diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 0ffff4f43..46e43bbc3 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -30,6 +30,7 @@ class ReportFactory(DjangoModelFactory): date = Faker("date") review = False not_billable = False + billed = False task = SubFactory("timed.projects.factories.TaskFactory") user = SubFactory("timed.employment.factories.UserFactory") diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index ceb364cfa..0ecbc9f08 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -88,6 +88,7 @@ class ReportFilterSet(FilterSet): review = NumberFilter(field_name="review") editable = NumberFilter(method="filter_editable") not_billable = NumberFilter(field_name="not_billable") + billed = NumberFilter(field_name="billed") verified = NumberFilter( field_name="verified_by_id", lookup_expr="isnull", exclude=True ) @@ -111,7 +112,7 @@ def get_editable_query(): Q(user__in=user.supervisees.values("id")) | Q(task__project__in=user.reviews.values("id")) | Q(user=user) - ) & Q(verified_by__isnull=True) + ) & ~(Q(verified_by__isnull=False) & Q(billed=True)) if value: # editable if user.is_superuser: diff --git a/timed/tracking/migrations/0013_report_billed.py b/timed/tracking/migrations/0013_report_billed.py new file mode 100644 index 000000000..0216ab4ef --- /dev/null +++ b/timed/tracking/migrations/0013_report_billed.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.15 on 2020-12-01 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tracking", "0012_migrate_report_review_false"), + ] + + operations = [ + migrations.AddField( + model_name="report", + name="billed", + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index bbec9416d..35851354a 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -88,6 +88,7 @@ class Report(models.Model): duration = models.DurationField() review = models.BooleanField(default=False) not_billable = models.BooleanField(default=False) + billed = models.BooleanField(default=False) task = models.ForeignKey( "projects.Task", on_delete=models.PROTECT, related_name="reports" ) @@ -105,11 +106,16 @@ def save(self, *args, **kwargs): This rounds the duration of the report to the nearest 15 minutes. However, the duration must at least be 15 minutes long. + + Sets the billed state to the billed state of the project. """ self.duration = timedelta( seconds=max(15 * 60, round(self.duration.seconds / (15 * 60)) * (15 * 60)) ) + if not self.pk or not self.billed: + self.billed = self.task.project.billed + super().save(*args, **kwargs) def __str__(self): diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 2615b6d51..e52e56d6b 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -130,12 +130,12 @@ def validate(self, data): new_verified_by = data.get("verified_by") task = data.get("task") or self.instance.task review = data.get("review") + billed = data.get("billed") + is_reviewer = ( + user.is_superuser or task.project.reviewers.filter(id=user.id).exists() + ) if new_verified_by != current_verified_by: - is_reviewer = ( - user.is_superuser or task.project.reviewers.filter(id=user.id).exists() - ) - if not is_reviewer: raise ValidationError(_("Only reviewer may verify reports.")) @@ -146,6 +146,10 @@ def validate(self, data): raise ValidationError( _("Report can't both be set as `review` and `verified`.") ) + + if not is_reviewer and billed: + raise ValidationError(_("Only reviewers may bill reports.")) + return data class Meta: @@ -156,6 +160,7 @@ class Meta: "duration", "review", "not_billable", + "billed", "task", "activity", "user", @@ -172,6 +177,7 @@ class ReportBulkSerializer(Serializer): comment = serializers.CharField(allow_null=True, required=False) review = serializers.NullBooleanField(required=False) not_billable = serializers.NullBooleanField(required=False) + billed = serializers.NullBooleanField(required=False) verified = serializers.NullBooleanField(required=False) class Meta: @@ -201,6 +207,7 @@ class ReportIntersectionSerializer(Serializer): comment = SerializerMethodField() review = SerializerMethodField() not_billable = SerializerMethodField() + billed = SerializerMethodField() verified = SerializerMethodField() def _intersection(self, instance, field, model=None): @@ -237,6 +244,9 @@ def get_review(self, instance): def get_not_billable(self, instance): return self._intersection(instance, "not_billable") + def get_billed(self, instance): + return self._intersection(instance, "billed") + def get_verified(self, instance): queryset = instance["queryset"] queryset = queryset.annotate( diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 2d96aedab..59c79e34a 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -8,15 +8,14 @@ from django.utils.duration import duration_string from rest_framework import status -from timed.employment.factories import UserFactory -from timed.projects.factories import CostCenterFactory, ProjectFactory, TaskFactory -from timed.tracking.factories import ReportFactory - -def test_report_list(auth_client): +def test_report_list( + auth_client, + report_factory, +): user = auth_client.user - ReportFactory.create(user=user) - report = ReportFactory.create(user=user, duration=timedelta(hours=1)) + report_factory.create(user=user) + report = report_factory.create(user=user, duration=timedelta(hours=1)) url = reverse("report-list") response = auth_client.get( @@ -39,8 +38,11 @@ def test_report_list(auth_client): assert json["meta"]["total-time"] == "01:00:00" -def test_report_intersection_full(auth_client): - report = ReportFactory.create() +def test_report_intersection_full( + auth_client, + report_factory, +): + report = report_factory.create() url = reverse("report-intersection") response = auth_client.get( @@ -72,6 +74,7 @@ def test_report_intersection_full(auth_client): "not-billable": False, "verified": False, "review": False, + "billed": False, }, "relationships": { "customer": { @@ -91,10 +94,16 @@ def test_report_intersection_full(auth_client): assert json == expected -def test_report_intersection_partial(auth_client): +def test_report_intersection_partial( + auth_client, + report_factory, +): user = auth_client.user - ReportFactory.create(review=True, not_billable=True, comment="test") - ReportFactory.create(verified_by=user, comment="test") + report = report_factory.create(review=True, not_billable=True, comment="test") + report_factory.create(verified_by=user, comment="test") + # Billed is not set on create because the factory doesnt seem to work with that + report.billed = True + report.save() url = reverse("report-intersection") response = auth_client.get(url) @@ -110,6 +119,7 @@ def test_report_intersection_partial(auth_client): "not-billable": None, "verified": None, "review": None, + "billed": None, }, "relationships": { "customer": {"data": None}, @@ -122,10 +132,13 @@ def test_report_intersection_partial(auth_client): assert json == expected -def test_report_list_filter_id(auth_client): - report_1 = ReportFactory.create(date="2017-01-01") - report_2 = ReportFactory.create(date="2017-02-01") - ReportFactory.create() +def test_report_list_filter_id( + auth_client, + report_factory, +): + report_1 = report_factory.create(date="2017-01-01") + report_2 = report_factory.create(date="2017-02-01") + report_factory.create() url = reverse("report-list") @@ -139,9 +152,12 @@ def test_report_list_filter_id(auth_client): assert json["data"][1]["id"] == str(report_2.id) -def test_report_list_filter_id_empty(auth_client): +def test_report_list_filter_id_empty( + auth_client, + report_factory, +): """Test that empty id filter is ignored.""" - ReportFactory.create() + report_factory.create() url = reverse("report-list") @@ -151,9 +167,12 @@ def test_report_list_filter_id_empty(auth_client): assert len(json["data"]) == 1 -def test_report_list_filter_reviewer(auth_client): +def test_report_list_filter_reviewer( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) report.task.project.reviewers.add(user) url = reverse("report-list") @@ -165,10 +184,13 @@ def test_report_list_filter_reviewer(auth_client): assert json["data"][0]["id"] == str(report.id) -def test_report_list_filter_verifier(auth_client): +def test_report_list_filter_verifier( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(verified_by=user) - ReportFactory.create() + report = report_factory.create(verified_by=user) + report_factory.create() url = reverse("report-list") @@ -179,10 +201,13 @@ def test_report_list_filter_verifier(auth_client): assert json["data"][0]["id"] == str(report.id) -def test_report_list_filter_editable_owner(auth_client): +def test_report_list_filter_editable_owner( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(user=user) - ReportFactory.create() + report = report_factory.create(user=user) + report_factory.create() url = reverse("report-list") @@ -193,10 +218,13 @@ def test_report_list_filter_editable_owner(auth_client): assert json["data"][0]["id"] == str(report.id) -def test_report_list_filter_not_editable_owner(auth_client): +def test_report_list_filter_not_editable_owner( + auth_client, + report_factory, +): user = auth_client.user - ReportFactory.create(user=user) - report = ReportFactory.create() + report_factory.create(user=user) + report = report_factory.create() url = reverse("report-list") @@ -207,23 +235,25 @@ def test_report_list_filter_not_editable_owner(auth_client): assert json["data"][0]["id"] == str(report.id) -def test_report_list_filter_editable_reviewer(auth_client): +def test_report_list_filter_editable_reviewer( + auth_client, report_factory, user_factory +): user = auth_client.user # not editable report - ReportFactory.create() + report_factory.create() # editable reports # 1st report of current user - ReportFactory.create(user=user) + report_factory.create(user=user) # 2nd case: report of a project which has several # reviewers and report is created by current user - report = ReportFactory.create(user=user) - other_user = UserFactory.create() + report = report_factory.create(user=user) + other_user = user_factory.create() report.task.project.reviewers.add(user) report.task.project.reviewers.add(other_user) # 3rd case: report by other user and current user # is the reviewer - reviewer_report = ReportFactory.create() + reviewer_report = report_factory.create() reviewer_report.task.project.reviewers.add(user) url = reverse("report-list") @@ -234,8 +264,8 @@ def test_report_list_filter_editable_reviewer(auth_client): assert len(json["data"]) == 3 -def test_report_list_filter_editable_superuser(superadmin_client): - report = ReportFactory.create() +def test_report_list_filter_editable_superuser(superadmin_client, report_factory): + report = report_factory.create() url = reverse("report-list") @@ -246,8 +276,8 @@ def test_report_list_filter_editable_superuser(superadmin_client): assert json["data"][0]["id"] == str(report.id) -def test_report_list_filter_not_editable_superuser(superadmin_client): - ReportFactory.create() +def test_report_list_filter_not_editable_superuser(superadmin_client, report_factory): + report_factory.create() url = reverse("report-list") @@ -257,21 +287,25 @@ def test_report_list_filter_not_editable_superuser(superadmin_client): assert len(json["data"]) == 0 -def test_report_list_filter_editable_supervisor(auth_client): +def test_report_list_filter_editable_supervisor( + auth_client, + report_factory, + user_factory, +): user = auth_client.user # not editable report - ReportFactory.create() + report_factory.create() # editable reports # 1st case: report by current user - ReportFactory.create(user=user) + report_factory.create(user=user) # 2nd case: report by current user with several supervisors - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) report.user.supervisors.add(user) - other_user = UserFactory.create() + other_user = user_factory.create() report.user.supervisors.add(other_user) # 3rd case: report by different user with current user as supervisor - supervisor_report = ReportFactory.create() + supervisor_report = report_factory.create() supervisor_report.user.supervisors.add(user) url = reverse("report-list") @@ -282,7 +316,28 @@ def test_report_list_filter_editable_supervisor(auth_client): assert len(json["data"]) == 3 -def test_report_export_missing_type(auth_client): +def test_report_list_filter_billed( + auth_client, + report_factory, +): + report = report_factory.create() + # Billed is not set on create because the factory doesnt seem to work with that + report.billed = True + report.save() + + url = reverse("report-list") + + response = auth_client.get(url, data={"billed": 1}) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) + + +def test_report_export_missing_type( + auth_client, + report_factory, +): user = auth_client.user url = reverse("report-export") @@ -291,9 +346,12 @@ def test_report_export_missing_type(auth_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_detail(auth_client): +def test_report_detail( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) url = reverse("report-detail", args=[report.id]) response = auth_client.get(url) @@ -301,10 +359,10 @@ def test_report_detail(auth_client): assert response.status_code == status.HTTP_200_OK -def test_report_create(auth_client): +def test_report_create(auth_client, report_factory, task_factory): """Should create a new report and automatically set the user.""" user = auth_client.user - task = TaskFactory.create() + task = task_factory.create() data = { "data": { @@ -333,9 +391,50 @@ def test_report_create(auth_client): assert json["data"]["relationships"]["task"]["data"]["id"] == str(task.id) -def test_report_update_bulk(auth_client): - task = TaskFactory.create() - report = ReportFactory.create(user=auth_client.user) +def test_report_create_billed( + auth_client, report_factory, project_factory, task_factory +): + """Should create a new report and automatically set the user.""" + user = auth_client.user + project = project_factory.create(billed=True) + task = task_factory.create(project=project) + + data = { + "data": { + "type": "reports", + "id": None, + "attributes": { + "comment": "foo", + "duration": "00:50:00", + "date": "2017-02-01", + }, + "relationships": { + "task": {"data": {"type": "tasks", "id": task.id}}, + "verified-by": {"data": None}, + }, + } + } + + url = reverse("report-list") + + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED + + json = response.json() + assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) + + assert json["data"]["relationships"]["task"]["data"]["id"] == str(task.id) + + assert json["data"]["attributes"]["billed"] + + +def test_report_update_bulk( + auth_client, + report_factory, + task_factory, +): + task = task_factory.create() + report = report_factory.create(user=auth_client.user) url = reverse("report-bulk") @@ -354,8 +453,11 @@ def test_report_update_bulk(auth_client): assert report.task == task -def test_report_update_bulk_verify_non_reviewer(auth_client): - ReportFactory.create(user=auth_client.user) +def test_report_update_bulk_verify_non_reviewer( + auth_client, + report_factory, +): + report_factory.create(user=auth_client.user) url = reverse("report-bulk") @@ -367,9 +469,9 @@ def test_report_update_bulk_verify_non_reviewer(auth_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_bulk_verify_superuser(superadmin_client): +def test_report_update_bulk_verify_superuser(superadmin_client, report_factory): user = superadmin_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) url = reverse("report-bulk") @@ -384,9 +486,12 @@ def test_report_update_bulk_verify_superuser(superadmin_client): assert report.verified_by == user -def test_report_update_bulk_verify_reviewer(auth_client): +def test_report_update_bulk_verify_reviewer( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) report.task.project.reviewers.add(user) url = reverse("report-bulk") @@ -407,9 +512,9 @@ def test_report_update_bulk_verify_reviewer(auth_client): assert report.comment == "some comment" -def test_report_update_bulk_reset_verify(superadmin_client): +def test_report_update_bulk_reset_verify(superadmin_client, report_factory): user = superadmin_client.user - report = ReportFactory.create(verified_by=user) + report = report_factory.create(verified_by=user) url = reverse("report-bulk") @@ -424,7 +529,10 @@ def test_report_update_bulk_reset_verify(superadmin_client): assert report.verified_by_id is None -def test_report_update_bulk_not_editable(auth_client): +def test_report_update_bulk_not_editable( + auth_client, + report_factory, +): url = reverse("report-bulk") data = { @@ -439,10 +547,13 @@ def test_report_update_bulk_not_editable(auth_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_verified_as_non_staff_but_owner(auth_client): +def test_report_update_verified_as_non_staff_but_owner( + auth_client, + report_factory, +): """Test that an owner (not staff) may not change a verified report.""" user = auth_client.user - report = ReportFactory.create( + report = report_factory.create( user=user, verified_by=user, duration=timedelta(hours=2) ) @@ -460,11 +571,11 @@ def test_report_update_verified_as_non_staff_but_owner(auth_client): assert response.status_code == status.HTTP_403_FORBIDDEN -def test_report_update_owner(auth_client): +def test_report_update_owner(auth_client, report_factory, task_factory): """Should update an existing report.""" user = auth_client.user - report = ReportFactory.create(user=user) - task = TaskFactory.create() + report = report_factory.create(user=user) + task = task_factory.create() data = { "data": { @@ -497,9 +608,12 @@ def test_report_update_owner(auth_client): ) -def test_report_update_date_reviewer(auth_client): +def test_report_update_date_reviewer( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create() + report = report_factory.create() report.task.project.reviewers.add(user) data = { @@ -516,9 +630,12 @@ def test_report_update_date_reviewer(auth_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_duration_reviewer(auth_client): +def test_report_update_duration_reviewer( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(duration=timedelta(hours=2)) + report = report_factory.create(duration=timedelta(hours=2)) report.task.project.reviewers.add(user) data = { @@ -535,9 +652,12 @@ def test_report_update_duration_reviewer(auth_client): assert res.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_by_user(auth_client): +def test_report_update_by_user( + auth_client, + report_factory, +): """Updating of report belonging to different user is not allowed.""" - report = ReportFactory.create() + report = report_factory.create() data = { "data": { "type": "reports", @@ -551,9 +671,12 @@ def test_report_update_by_user(auth_client): assert response.status_code == status.HTTP_403_FORBIDDEN -def test_report_update_verified_and_review_reviewer(auth_client): +def test_report_update_verified_and_review_reviewer( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(duration=timedelta(hours=2)) + report = report_factory.create(duration=timedelta(hours=2)) report.task.project.reviewers.add(user) data = { @@ -573,10 +696,13 @@ def test_report_update_verified_and_review_reviewer(auth_client): assert res.status_code == status.HTTP_400_BAD_REQUEST -def test_report_set_verified_by_user(auth_client): +def test_report_set_verified_by_user( + auth_client, + report_factory, +): """Test that normal user may not verify report.""" user = auth_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) data = { "data": { "type": "reports", @@ -592,9 +718,12 @@ def test_report_set_verified_by_user(auth_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_reviewer(auth_client): +def test_report_update_reviewer( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) report.task.project.reviewers.add(user) data = { @@ -614,9 +743,12 @@ def test_report_update_reviewer(auth_client): assert response.status_code == status.HTTP_200_OK -def test_report_update_supervisor(auth_client): +def test_report_update_supervisor( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) report.user.supervisors.add(user) data = { @@ -633,10 +765,10 @@ def test_report_update_supervisor(auth_client): assert response.status_code == status.HTTP_200_OK -def test_report_verify_other_user(superadmin_client): +def test_report_verify_other_user(superadmin_client, report_factory, user_factory): """Verify that superuser may not verify to other user.""" - user = UserFactory.create() - report = ReportFactory.create() + user = user_factory.create() + report = report_factory.create() data = { "data": { @@ -653,11 +785,43 @@ def test_report_verify_other_user(superadmin_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_reset_verified_by_reviewer(auth_client): - """Test that reviewer may not change verified report.""" +def test_report_reset_verified_by_reviewer( + auth_client, + report_factory, +): + """Test that reviewer may change verified report.""" + user = auth_client.user + report = report_factory.create(user=user, verified_by=user) + report.task.project.reviewers.add(user) + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {"comment": "foobar"}, + "relationships": {"verified-by": {"data": None}}, + } + } + + url = reverse("report-detail", args=[report.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.refresh_from_db() + report.verified_by = None + + +def test_report_reset_verified_and_billed_by_reviewer( + auth_client, + report_factory, +): + """Test that reviewer may not change verified and billed report.""" user = auth_client.user - report = ReportFactory.create(user=user, verified_by=user) + report = report_factory.create(user=user, verified_by=user) report.task.project.reviewers.add(user) + # Billed is not set on create because the factory doesnt seem to work with that + report.billed = True + report.save() data = { "data": { @@ -673,18 +837,21 @@ def test_report_reset_verified_by_reviewer(auth_client): assert response.status_code == status.HTTP_403_FORBIDDEN -def test_report_delete(auth_client): +def test_report_delete( + auth_client, + report_factory, +): user = auth_client.user - report = ReportFactory.create(user=user) + report = report_factory.create(user=user) url = reverse("report-detail", args=[report.id]) response = auth_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT -def test_report_round_duration(db): +def test_report_round_duration(db, report_factory): """Should round the duration of a report to 15 minutes.""" - report = ReportFactory.create() + report = report_factory.create() report.duration = timedelta(hours=1, minutes=7) report.save() @@ -711,29 +878,31 @@ def test_report_list_no_result(admin_client): assert json["meta"]["total-time"] == "00:00:00" -def test_report_delete_superuser(superadmin_client): +def test_report_delete_superuser(superadmin_client, report_factory): """Test that superuser may not delete reports of other users.""" - report = ReportFactory.create() + report = report_factory.create() url = reverse("report-detail", args=[report.id]) response = superadmin_client.delete(url) assert response.status_code == status.HTTP_403_FORBIDDEN -def test_report_list_filter_cost_center(auth_client): - cost_center = CostCenterFactory.create() +def test_report_list_filter_cost_center( + auth_client, report_factory, cost_center_factory, project_factory, task_factory +): + cost_center = cost_center_factory.create() # 1st valid case: report with task of given cost center # but different project cost center - task = TaskFactory.create(cost_center=cost_center) - report_task = ReportFactory.create(task=task) + task = task_factory.create(cost_center=cost_center) + report_task = report_factory.create(task=task) # 2nd valid case: report with project of given cost center - project = ProjectFactory.create(cost_center=cost_center) - task = TaskFactory.create(cost_center=None, project=project) - report_project = ReportFactory.create(task=task) + project = project_factory.create(cost_center=cost_center) + task = task_factory.create(cost_center=None, project=project) + report_project = report_factory.create(task=task) # Invalid case: report without cost center - project = ProjectFactory.create(cost_center=None) - task = TaskFactory.create(cost_center=None, project=project) - ReportFactory.create(task=task) + project = project_factory.create(cost_center=None) + task = task_factory.create(cost_center=None, project=project) + report_factory.create(task=task) url = reverse("report-list") @@ -1019,3 +1188,82 @@ def test_report_update_bulk_review_and_verified( query_params = f"?id={report.id}" response = superadmin_client.post(url + query_params, data) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_report_update_bulk_bill_non_reviewer( + auth_client, + report_factory, +): + report_factory.create(user=auth_client.user) + + url = reverse("report-bulk") + + data = {"data": {"type": "report-bulks", "id": None, "attributes": {"billed": 1}}} + + response = auth_client.post(url + "?editable=1", data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_report_update_bulk_bill_reviewer( + auth_client, + report_factory, +): + user = auth_client.user + report = report_factory.create(user=user) + report.task.project.reviewers.add(user) + + url = reverse("report-bulk") + + data = { + "data": { + "type": "report-bulks", + "id": None, + "attributes": {"billed": True}, + } + } + + response = auth_client.post(url + "?editable=1&reviewer={0}".format(user.id), data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + report.refresh_from_db() + assert report.billed + + +def test_report_update_billed_user( + auth_client, + report_factory, +): + report = report_factory.create() + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {"billed": 1}, + } + } + + url = reverse("report-detail", args=[report.id]) + + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_report_set_billed_by_user( + auth_client, + report_factory, +): + """Test that normal user may not bill report.""" + user = auth_client.user + report = report_factory.create(user=user) + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {"billed": 1}, + } + } + + url = reverse("report-detail", args=[report.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 9cf42e645..b86830ea1 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -12,6 +12,7 @@ from timed.permissions import ( IsAuthenticated, + IsNotBilledAndVerfied, IsNotDelete, IsNotTransferred, IsOwner, @@ -77,9 +78,9 @@ class ReportViewSet(ModelViewSet): permission_classes = [ # superuser may edit all reports but not delete IsSuperUser & IsNotDelete - # reviewer and supervisor may change unverified reports + # reviewer and supervisor may not change reports which are verfied and billed # but not delete them - | (IsReviewer | IsSupervisor) & IsUnverified & IsNotDelete + | (IsReviewer | IsSupervisor) & IsNotBilledAndVerfied & IsNotDelete # owner may only change its own unverified reports | IsOwner & IsUnverified # all authenticated users may read all reports @@ -196,6 +197,15 @@ def bulk(self, request): _("Reports can't both be set as `review` and `verified`.") ) + if ( + serializer.validated_data.get("billed", None) is not None + and not user.is_superuser + and str(request.query_params.get("reviewer")) != str(user.id) + ): + raise exceptions.ParseError( + _("Reviewer filter needs to be set to verifying user") + ) + if fields: tasks.notify_user_changed_reports(queryset, fields, user) queryset.update(**fields) From 9a6dec22ab4627395f67ab124b50dffa29294e10 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 14 Dec 2020 00:14:45 +0000 Subject: [PATCH 762/980] chore(deps-dev): bump pytest from 6.1.2 to 6.2.0 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.1.2 to 6.2.0. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.1.2...6.2.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index fd0a4ffdb..6b79b9f41 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.3.0 ipdb==0.13.4 isort==5.6.4 pdbpp==0.10.2 -pytest==6.1.2 +pytest==6.2.0 pytest-cov==2.10.1 pytest-django==4.1.0 pytest-env==0.6.2 From 62295bac19f302fa45281a72edb09397e3cbc4c6 Mon Sep 17 00:00:00 2001 From: Yelinz Date: Mon, 14 Dec 2020 09:46:37 +0100 Subject: [PATCH 763/980] fix(tracking): update billed if not sent with request --- timed/tracking/models.py | 2 +- timed/tracking/serializers.py | 3 +++ timed/tracking/tests/test_report.py | 42 +++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 35851354a..2ae924fb5 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -113,7 +113,7 @@ def save(self, *args, **kwargs): seconds=max(15 * 60, round(self.duration.seconds / (15 * 60)) * (15 * 60)) ) - if not self.pk or not self.billed: + if not self.pk: self.billed = self.task.project.billed super().save(*args, **kwargs) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index e52e56d6b..0ffab144c 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -150,6 +150,9 @@ def validate(self, data): if not is_reviewer and billed: raise ValidationError(_("Only reviewers may bill reports.")) + if billed is None: + data["billed"] = task.project.billed + return data class Meta: diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 59c79e34a..5fa654980 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -318,9 +318,8 @@ def test_report_list_filter_editable_supervisor( def test_report_list_filter_billed( auth_client, - report_factory, + report, ): - report = report_factory.create() # Billed is not set on create because the factory doesnt seem to work with that report.billed = True report.save() @@ -1267,3 +1266,42 @@ def test_report_set_billed_by_user( url = reverse("report-detail", args=[report.id]) response = auth_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_report_update_billed(auth_client, report_factory, task): + user = auth_client.user + report = report_factory.create(user=user) + report.task.project.billed = True + report.task.project.save() + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {"comment": "foobar"}, + } + } + + url = reverse("report-detail", args=[report.id]) + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.refresh_from_db() + assert report.billed + + data = { + "data": { + "type": "reports", + "id": report.id, + "relationships": { + "project": {"data": {"type": "projects", "id": task.project.id}}, + "task": {"data": {"type": "tasks", "id": task.id}}, + }, + } + } + + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.refresh_from_db() + assert not report.billed From d25e64fd4c898757acb565996173f460f636c6a6 Mon Sep 17 00:00:00 2001 From: Yelinz Date: Mon, 14 Dec 2020 16:38:44 +0100 Subject: [PATCH 764/980] fix(tracking): set billed from project on report --- timed/permissions.py | 2 +- timed/tracking/models.py | 5 ----- timed/tracking/serializers.py | 2 +- timed/tracking/tests/test_report.py | 27 +++++++++++++++++++++++++++ timed/tracking/views.py | 3 +++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/timed/permissions.py b/timed/permissions.py index 3b1816b4e..26cd01740 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -140,4 +140,4 @@ class IsNotBilledAndVerfied(BasePermission): """Allows access only to not billed and not verfied objects.""" def has_object_permission(self, request, view, obj): - return not (obj.billed is True and obj.verified_by_id is not None) + return not obj.billed or obj.verified_by_id is None diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 2ae924fb5..f18ec31a6 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -106,16 +106,11 @@ def save(self, *args, **kwargs): This rounds the duration of the report to the nearest 15 minutes. However, the duration must at least be 15 minutes long. - - Sets the billed state to the billed state of the project. """ self.duration = timedelta( seconds=max(15 * 60, round(self.duration.seconds / (15 * 60)) * (15 * 60)) ) - if not self.pk: - self.billed = self.task.project.billed - super().save(*args, **kwargs) def __str__(self): diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 0ffab144c..00ff1271c 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -150,7 +150,7 @@ def validate(self, data): if not is_reviewer and billed: raise ValidationError(_("Only reviewers may bill reports.")) - if billed is None: + if not self.instance or billed is None: data["billed"] = task.project.billed return data diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 5fa654980..1c85edb0b 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1305,3 +1305,30 @@ def test_report_update_billed(auth_client, report_factory, task): report.refresh_from_db() assert not report.billed + + +def test_report_update_bulk_billed(auth_client, report_factory, task): + user = auth_client.user + report = report_factory.create(user=user) + report.task.project.reviewers.add(user) + task.project.billed = True + task.project.save() + + url = reverse("report-bulk") + + data = { + "data": { + "type": "report-bulks", + "id": None, + "relationships": { + "project": {"data": {"type": "projects", "id": task.project.id}}, + "task": {"data": {"type": "tasks", "id": task.id}}, + }, + } + } + + response = auth_client.post(url + "?editable=1&reviewer={0}".format(user.id), data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + report.refresh_from_db() + assert report.billed diff --git a/timed/tracking/views.py b/timed/tracking/views.py index b86830ea1..173bd9ed1 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -206,6 +206,9 @@ def bulk(self, request): _("Reviewer filter needs to be set to verifying user") ) + if "task" in fields: + fields["billed"] = fields["task"].project.billed + if fields: tasks.notify_user_changed_reports(queryset, fields, user) queryset.update(**fields) From e75b50906fc0e7b4a105d576ff6fd8454ccdb95c Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 30 Dec 2020 12:06:03 +0100 Subject: [PATCH 765/980] chore: move non-essential services to compose override --- docker-compose.override.yml | 31 +++++++++++++++++++++++++++++++ docker-compose.yml | 35 ----------------------------------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 674d302bc..9119f2939 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -19,12 +19,43 @@ services: - timed.local frontend: + image: adfinissygroup/timed-frontend:latest + ports: + - 4200:80 environment: - TIMED_SSO_CLIENT_HOST=http://timed.local/auth/realms/timed/protocol/openid-connect - TIMED_SSO_CLIENT_ID=timed-public networks: - timed.local + keycloak: + image: jboss/keycloak:10.0.1 + volumes: + - ./dev-config/keycloak-config.json:/etc/keycloak/keycloak-config.json:ro + depends_on: + - db + environment: + - DB_VENDOR=postgres + - DB_ADDR=db + - DB_USER=timed + - DB_DATABASE=timed + - DB_PASSWORD=timed + - PROXY_ADDRESS_FORWARDING=true + command: ["-Dkeycloak.migration.action=import", "-Dkeycloak.migration.provider=singleFile", "-Dkeycloak.migration.file=/etc/keycloak/keycloak-config.json", "-b", "0.0.0.0"] + networks: + - timed.local + + proxy: + image: nginx:1.17.10-alpine + ports: + - 80:80 + volumes: + - ./dev-config/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + timed.local: + aliases: + - timed.local + mailhog: image: mailhog/mailhog ports: diff --git a/docker-compose.yml b/docker-compose.yml index 5e46ca97c..cf1755ec9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,13 +13,6 @@ services: networks: - timed.local - frontend: - image: adfinissygroup/timed-frontend:latest - ports: - - 4200:80 - networks: - - timed.local - backend: build: . ports: @@ -34,34 +27,6 @@ services: networks: - timed.local - keycloak: - image: jboss/keycloak:10.0.1 - volumes: - - ./dev-config/keycloak-config.json:/etc/keycloak/keycloak-config.json:ro - depends_on: - - db - environment: - - DB_VENDOR=postgres - - DB_ADDR=db - - DB_USER=timed - - DB_DATABASE=timed - - DB_PASSWORD=timed - - PROXY_ADDRESS_FORWARDING=true - command: ["-Dkeycloak.migration.action=import", "-Dkeycloak.migration.provider=singleFile", "-Dkeycloak.migration.file=/etc/keycloak/keycloak-config.json", "-b", "0.0.0.0"] - networks: - - timed.local - - proxy: - image: nginx:1.17.10-alpine - ports: - - 80:80 - volumes: - - ./dev-config/nginx.conf:/etc/nginx/conf.d/default.conf:ro - networks: - timed.local: - aliases: - - timed.local - volumes: dbdata: From b3b7fab129830515b2daa4a5d2842614b2a1bd1f Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 30 Dec 2020 12:13:32 +0100 Subject: [PATCH 766/980] chore: move to github actions --- .github/workflows/test.yml | 26 ++++++++++++++++++++++++++ .travis.yml | 29 ----------------------------- README.md | 2 +- 3 files changed, 27 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..9c27f22c0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Test + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * 0' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build the project + run: | + docker-compose -f docker-compose.yml up-d --build + - name: Lint the code + run: | + docker-compose exec -T backend black --check . + docker-compose exec -T backend flake8 + docker-compose exec -T backend ./manage.py makemigrations --check --dry-run --no-input + docker-compose exec -T backend reuse lint + - name: Run pytest + run: docker-compose exec -T backend pytest --no-cov-on-fail --cov --create-db -vv + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8b3aab46f..000000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -language: python - -python: - - "3.6" - -services: - - postgresql - -cache: - pip: true - directories: - - .hypothesis - -install: - - echo "ENV=travis" > .env - - pip install --upgrade -r requirements.txt -r requirements-dev.txt - -before_script: - - psql -c "CREATE ROLE timed CREATEDB LOGIN PASSWORD 'timed';" -U postgres - - psql -c "CREATE DATABASE timed;" -U postgres - -script: - - black --check . - - flake8 - - ./manage.py makemigrations --check --dry-run --no-input - - pytest --no-cov-on-fail --cov --create-db - -after_success: - - bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index 9b086310b..cb5909d66 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Timed Backend -[![Build Status](https://travis-ci.org/adfinis-sygroup/timed-backend.svg?branch=master)](https://travis-ci.org/adfinis-sygroup/timed-backend) +[![Build Status](https://github.com/adfinis-sygroup/timed-backend/workflows/Test/badge.svg)](https://github.com/adfinis-sygroup/timed-backend/actions?query=workflow%3A%22Test%22) [![Codecov](https://codecov.io/gh/adfinis-sygroup/timed-backend/branch/master/graph/badge.svg)](https://codecov.io/gh/adfinis-sygroup/timed-backend) [![Pyup](https://pyup.io/repos/github/adfinis-sygroup/timed-backend/shield.svg)](https://pyup.io/account/repos/github/adfinis-sygroup/timed-backend/) [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/adfinis-sygroup/timed-backend) From f5ad9b9b0c4c659cb817699661c0585ec7131c1d Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 30 Dec 2020 15:53:09 +0100 Subject: [PATCH 767/980] chore: gh actions typo --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c27f22c0..1ccada96b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v2 - name: Build the project run: | - docker-compose -f docker-compose.yml up-d --build + docker-compose -f docker-compose.yml up -d --build - name: Lint the code run: | docker-compose exec -T backend black --check . From 561999e220803eaf78f601f0101a854a5fd32326 Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Wed, 30 Dec 2020 16:00:20 +0100 Subject: [PATCH 768/980] chore: actions install dev requirements --- .github/workflows/test.yml | 2 +- timed/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ccada96b..2959ec03c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,12 +14,12 @@ jobs: - name: Build the project run: | docker-compose -f docker-compose.yml up -d --build + docker-compose -f docker-compose.yml exec -T backend pip install -r requirements-dev.txt - name: Lint the code run: | docker-compose exec -T backend black --check . docker-compose exec -T backend flake8 docker-compose exec -T backend ./manage.py makemigrations --check --dry-run --no-input - docker-compose exec -T backend reuse lint - name: Run pytest run: docker-compose exec -T backend pytest --no-cov-on-fail --cov --create-db -vv - name: Upload coverage to Codecov diff --git a/timed/settings.py b/timed/settings.py index 280355e73..c36972e7a 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -9,7 +9,7 @@ django_root = environ.Path(__file__) - 2 ENV_FILE = env.str("DJANGO_ENV_FILE", default=django_root(".env")) -if os.path.exists(ENV_FILE): +if os.path.exists(ENV_FILE): # pragma: no cover environ.Env.read_env(ENV_FILE) # per default production is enabled for security reasons From 6bde934cdbf13c05e8516ccdc10b08f9054e945c Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Thu, 7 Jan 2021 11:25:04 +0100 Subject: [PATCH 769/980] chore(ci): drop codecov There is no gain from codecov, coverage is enforced in pytest config. --- .github/workflows/test.yml | 6 ++---- README.md | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2959ec03c..3570ea001 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,8 +13,8 @@ jobs: - uses: actions/checkout@v2 - name: Build the project run: | - docker-compose -f docker-compose.yml up -d --build - docker-compose -f docker-compose.yml exec -T backend pip install -r requirements-dev.txt + docker-compose up -d --build backend + docker-compose exec -T backend pip install -r requirements-dev.txt - name: Lint the code run: | docker-compose exec -T backend black --check . @@ -22,5 +22,3 @@ jobs: docker-compose exec -T backend ./manage.py makemigrations --check --dry-run --no-input - name: Run pytest run: docker-compose exec -T backend pytest --no-cov-on-fail --cov --create-db -vv - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 diff --git a/README.md b/README.md index cb5909d66..1845c06dd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Timed Backend [![Build Status](https://github.com/adfinis-sygroup/timed-backend/workflows/Test/badge.svg)](https://github.com/adfinis-sygroup/timed-backend/actions?query=workflow%3A%22Test%22) -[![Codecov](https://codecov.io/gh/adfinis-sygroup/timed-backend/branch/master/graph/badge.svg)](https://codecov.io/gh/adfinis-sygroup/timed-backend) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/adfinis-sygroup/timed-backend/blob/master/setup.cfg) [![Pyup](https://pyup.io/repos/github/adfinis-sygroup/timed-backend/shield.svg)](https://pyup.io/account/repos/github/adfinis-sygroup/timed-backend/) [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/adfinis-sygroup/timed-backend) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) From f616a6526a0b41cc25495288a73f0fcaa9dc911b Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 26 Feb 2021 13:16:30 +0100 Subject: [PATCH 770/980] chore(deps): upgrade django to 3.1.7 upgrade python to 3.9 and bump other dependencies --- Dockerfile | 2 +- requirements-dev.txt | 12 +++---- requirements.txt | 8 ++--- setup.cfg | 4 +++ timed/employment/admin.py | 2 +- .../migrations/0013_auto_20210302_1136.py | 20 +++++++++++ timed/employment/models.py | 2 +- timed/employment/serializers.py | 2 +- timed/employment/views.py | 2 +- timed/forms.py | 2 +- timed/models.py | 2 +- timed/projects/admin.py | 2 +- timed/settings.py | 2 +- timed/subscription/admin.py | 2 +- timed/subscription/models.py | 2 +- timed/tracking/serializers.py | 10 +++--- .../tests/snapshots/snap_test_report.py | 34 +++++++++---------- timed/tracking/tests/test_activity.py | 16 ++++----- timed/tracking/views.py | 2 +- timed/urls.py | 16 ++++----- 20 files changed, 84 insertions(+), 60 deletions(-) create mode 100644 timed/employment/migrations/0013_auto_20210302_1136.py diff --git a/Dockerfile b/Dockerfile index 5e7adde34..c4d6429ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8 +FROM python:3.9 WORKDIR /app diff --git a/requirements-dev.txt b/requirements-dev.txt index 6b79b9f41..84bde1e27 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ -r requirements.txt black==20.8b1 -coverage==5.3 -factory-boy==3.1.0 +coverage==5.3.1 +factory-boy==3.2.0 flake8==3.8.4 flake8-blind-except==0.1.1 flake8-debugger==4.0.0 @@ -12,13 +12,13 @@ flake8-string-format==0.3.0 ipdb==0.13.4 isort==5.6.4 pdbpp==0.10.2 -pytest==6.2.0 +pytest==6.2.2 pytest-cov==2.10.1 pytest-django==4.1.0 pytest-env==0.6.2 -pytest-factoryboy==2.0.3 +pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 -pytest-mock==3.3.1 -pytest-randomly==3.4.1 +pytest-mock==3.5.1 +pytest-randomly==3.5.0 requests-mock==1.8.0 snapshottest==0.6.0 diff --git a/requirements.txt b/requirements.txt index d6fdfba04..48113157c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,13 @@ python-dateutil==2.8.1 -django==2.2.15 +django==3.1.7 # might remove this once we find out how the jsonapi extras_require work django-filter==2.4.0 django-multiselectfield==0.1.12 -djangorestframework==3.11.1 +djangorestframework==3.12.2 djangorestframework-jsonapi[django-filter]==3.1.0 mozilla-django-oidc==1.2.4 psycopg2==2.8.6 -pytz==2020.4 +pytz==2021.1 pyexcel-webio==0.1.4 pyexcel-io==0.6.4 django-excel==0.0.10 @@ -15,6 +15,6 @@ pyexcel-ods3==0.6.0 pyexcel-xlsx==0.6.0 pyexcel-ezodf==0.3.4 django-environ==0.4.5 -django-money==1.2.1 +django-money==1.3.1 python-redmine==2.3.0 uwsgi==2.0.19.1 diff --git a/setup.cfg b/setup.cfg index 09a8f52f5..ba0a0a0fb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,10 @@ env= filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning + ignore:Using a non-boolean value for an isnull lookup is deprecated, use True or False instead.:django.utils.deprecation.RemovedInDjango40Warning + # following is needed beceause of https://github.com/mozilla/mozilla-django-oidc/pull/371 + ignore:django.conf.urls.url().*:django.utils.deprecation.RemovedInDjango40Warning + [coverage:run] source=. diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 9a5159e54..0ef58780d 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -6,7 +6,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from timed.employment import models from timed.forms import DurationInHoursField diff --git a/timed/employment/migrations/0013_auto_20210302_1136.py b/timed/employment/migrations/0013_auto_20210302_1136.py new file mode 100644 index 000000000..e8a716cf7 --- /dev/null +++ b/timed/employment/migrations/0013_auto_20210302_1136.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.7 on 2021-03-02 10:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("employment", "0012_auto_20181026_1528"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="first_name", + field=models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index a0e060f80..11ef68c2d 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -8,7 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Sum, functions -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from timed.models import WeekdaysField from timed.tracking.models import Absence diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 458f70c8a..ff8b6b14a 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -6,7 +6,7 @@ from django.db.models import Max, Value from django.db.models.functions import Coalesce from django.utils.duration import duration_string -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework_json_api import relations from rest_framework_json_api.serializers import ( ModelSerializer, diff --git a/timed/employment/views.py b/timed/employment/views.py index 7e2dba3f4..c45db479d 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -5,7 +5,7 @@ from django.db.models import CharField, DateField, IntegerField, Q, Value from django.db.models.functions import Concat from django.shortcuts import get_object_or_404 -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions, status from rest_framework.decorators import action from rest_framework.response import Response diff --git a/timed/forms.py b/timed/forms.py index 3fd211be1..915fdf1ef 100644 --- a/timed/forms.py +++ b/timed/forms.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.forms.fields import FloatField -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class DurationInHoursField(FloatField): diff --git a/timed/models.py b/timed/models.py index 0d41ca2d9..887de5e44 100644 --- a/timed/models.py +++ b/timed/models.py @@ -1,5 +1,5 @@ """Basic model and field classes to be used in all apps.""" -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from multiselectfield import MultiSelectField diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 795ce83a3..a0a0daad7 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -3,7 +3,7 @@ from django import forms from django.contrib import admin from django.forms.models import BaseInlineFormSet -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from timed.forms import DurationInHoursField from timed.projects import models diff --git a/timed/settings.py b/timed/settings.py index c36972e7a..9367d533c 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -27,7 +27,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): DATABASES = { "default": { "ENGINE": env.str( - "DJANGO_DATABASE_ENGINE", default="django.db.backends.postgresql_psycopg2" + "DJANGO_DATABASE_ENGINE", default="django.db.backends.postgresql" ), "NAME": env.str("DJANGO_DATABASE_NAME", default="timed"), "USER": env.str("DJANGO_DATABASE_USER", default="timed"), diff --git a/timed/subscription/admin.py b/timed/subscription/admin.py index f0f0a06b1..63a4adaab 100644 --- a/timed/subscription/admin.py +++ b/timed/subscription/admin.py @@ -2,7 +2,7 @@ from django import forms from django.contrib import admin -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from timed.forms import DurationInHoursField diff --git a/timed/subscription/models.py b/timed/subscription/models.py index bc61ca0c9..6c40bcc1a 100644 --- a/timed/subscription/models.py +++ b/timed/subscription/models.py @@ -1,7 +1,7 @@ from django.conf import settings from django.db import models from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from djmoney.models.fields import MoneyField diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 00ff1271c..93b9a6062 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.db.models import BooleanField, Case, When from django.utils.duration import duration_string -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework_json_api import relations, serializers from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ( @@ -178,10 +178,10 @@ class ReportBulkSerializer(Serializer): queryset=Task.objects.all(), allow_null=True, required=False ) comment = serializers.CharField(allow_null=True, required=False) - review = serializers.NullBooleanField(required=False) - not_billable = serializers.NullBooleanField(required=False) - billed = serializers.NullBooleanField(required=False) - verified = serializers.NullBooleanField(required=False) + review = serializers.BooleanField(required=False, allow_null=True) + not_billable = serializers.BooleanField(required=False, allow_null=True) + billed = serializers.BooleanField(required=False, allow_null=True) + verified = serializers.BooleanField(required=False, allow_null=True) class Meta: resource_name = "report-bulks" diff --git a/timed/tracking/tests/snapshots/snap_test_report.py b/timed/tracking/tests/snapshots/snap_test_report.py index 754d1a4a1..f9b85226c 100644 --- a/timed/tracking/tests/snapshots/snap_test_report.py +++ b/timed/tracking/tests/snapshots/snap_test_report.py @@ -15,36 +15,36 @@ Reviewer: Test User -Date: 11/13/1983 -Duration: 0:15 (h:mm) -Task: Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC +Date: 06/27/1970 +Duration: 2:30 (h:mm) + Comment: some other comment -* Not_Billable - [old] True - [new] False +* Task + [old] Dickerson, George and White > Horizontal analyzing product > and Sons + [new] Dickerson, George and White > Horizontal analyzing product > Group --- -Date: 07/10/1985 -Duration: 2:30 (h:mm) - +Date: 11/02/1975 +Duration: 0:15 (h:mm) +Task: Dickerson, George and White > Horizontal analyzing product > Group Comment: some other comment -* Task - [old] Marsh, Gonzalez and Michael > Intuitive coherent hardware > and Sons - [new] Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC +* Not_Billable + [old] True + [new] False --- -Date: 06/01/1999 +Date: 05/14/1976 Duration: 3:15 (h:mm) * Task - [old] Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC - [new] Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC + [old] Dickerson, George and White > Horizontal analyzing product > Ltd + [new] Dickerson, George and White > Horizontal analyzing product > Group * Comment [old] foo @@ -52,9 +52,9 @@ --- -Date: 10/26/2002 +Date: 04/20/2005 Duration: 1:00 (h:mm) -Task: Marsh, Gonzalez and Michael > Intuitive coherent hardware > LLC +Task: Dickerson, George and White > Horizontal analyzing product > Group * Comment diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 6cf24892b..ec8d1883c 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -88,8 +88,8 @@ def test_activity_delete(auth_client): def test_activity_list_filter_active(auth_client): user = auth_client.user - ActivityFactory.create(user=user) - activity = ActivityFactory.create(user=user, to_time=None) + activity1 = ActivityFactory.create(user=user) + activity2 = ActivityFactory.create(user=user, to_time=None, task=activity1.task) url = reverse("activity-list") @@ -97,7 +97,7 @@ def test_activity_list_filter_active(auth_client): assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(activity.id) + assert json["data"][0]["id"] == str(activity2.id) def test_activity_list_filter_day(auth_client): @@ -235,19 +235,19 @@ def test_activity_active_update(auth_client): ) -def test_activity_set_to_time_none(auth_client): - activity = ActivityFactory.create(user=auth_client.user) - ActivityFactory.create(user=auth_client.user, to_time=None) +def test_activity_set_to_time_none(auth_client, activity_factory): + activity1 = activity_factory(user=auth_client.user, to_time=None) + activity2 = activity_factory(user=auth_client.user, task=activity1.task) data = { "data": { "type": "activities", - "id": activity.id, + "id": activity2.id, "attributes": {"to-time": None}, } } - url = reverse("activity-detail", args=[activity.id]) + url = reverse("activity-detail", args=[activity2.id]) res = auth_client.patch(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 173bd9ed1..2661d8ea9 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -4,7 +4,7 @@ from django.conf import settings from django.db.models import Case, CharField, F, Q, Value, When from django.http import HttpResponseBadRequest -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions, status from rest_framework.decorators import action from rest_framework.response import Response diff --git a/timed/urls.py b/timed/urls.py index f7148d26d..8c71b0738 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -1,14 +1,14 @@ """Root URL mapping.""" -from django.conf.urls import include, url from django.contrib import admin +from django.urls import include, re_path urlpatterns = [ - url(r"^admin/", admin.site.urls), - url(r"^api/v1/", include("timed.employment.urls")), - url(r"^api/v1/", include("timed.projects.urls")), - url(r"^api/v1/", include("timed.tracking.urls")), - url(r"^api/v1/", include("timed.reports.urls")), - url(r"^api/v1/", include("timed.subscription.urls")), - url(r"^oidc/", include("mozilla_django_oidc.urls")), + re_path(r"^admin/", admin.site.urls), + re_path(r"^api/v1/", include("timed.employment.urls")), + re_path(r"^api/v1/", include("timed.projects.urls")), + re_path(r"^api/v1/", include("timed.tracking.urls")), + re_path(r"^api/v1/", include("timed.reports.urls")), + re_path(r"^api/v1/", include("timed.subscription.urls")), + re_path(r"^oidc/", include("mozilla_django_oidc.urls")), ] From 31c6c15f6b097359fdc9999dd737a7d821fc79f2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 31 Mar 2021 08:39:42 +0000 Subject: [PATCH 771/980] chore(deps): bump djangorestframework from 3.12.2 to 3.12.4 Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.12.2 to 3.12.4. - [Release notes](https://github.com/encode/django-rest-framework/releases) - [Commits](https://github.com/encode/django-rest-framework/compare/3.12.2...3.12.4) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 48113157c..f820d653f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==3.1.7 # might remove this once we find out how the jsonapi extras_require work django-filter==2.4.0 django-multiselectfield==0.1.12 -djangorestframework==3.12.2 +djangorestframework==3.12.4 djangorestframework-jsonapi[django-filter]==3.1.0 mozilla-django-oidc==1.2.4 psycopg2==2.8.6 From 3c0421c41388323863aca9500dded688350c0245 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 2 Apr 2021 00:17:13 +0000 Subject: [PATCH 772/980] chore(deps-dev): bump coverage from 5.3.1 to 5.5 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.3.1 to 5.5. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.3.1...coverage-5.5) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 84bde1e27..8c1c63610 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt black==20.8b1 -coverage==5.3.1 +coverage==5.5 factory-boy==3.2.0 flake8==3.8.4 flake8-blind-except==0.1.1 From 5a0f4e3607ddd2d5bd3e4445965ef4ed4d036302 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 2 Apr 2021 00:17:34 +0000 Subject: [PATCH 773/980] chore(deps-dev): bump pytest-cov from 2.10.1 to 2.11.1 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.10.1 to 2.11.1. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.10.1...v2.11.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 84bde1e27..4a544c747 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.13.4 isort==5.6.4 pdbpp==0.10.2 pytest==6.2.2 -pytest-cov==2.10.1 +pytest-cov==2.11.1 pytest-django==4.1.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 From 49ba6dc3786a2069e2a0357a8cf5dadc031c91be Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 2 Apr 2021 00:18:09 +0000 Subject: [PATCH 774/980] chore(deps-dev): bump flake8-docstrings from 1.5.0 to 1.6.0 Bumps [flake8-docstrings](https://gitlab.com/pycqa/flake8-docstrings) from 1.5.0 to 1.6.0. - [Release notes](https://gitlab.com/pycqa/flake8-docstrings/tags) - [Changelog](https://gitlab.com/pycqa/flake8-docstrings/blob/master/HISTORY.rst) - [Commits](https://gitlab.com/pycqa/flake8-docstrings/compare/1.5.0...1.6.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 84bde1e27..f51f99885 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,7 @@ flake8==3.8.4 flake8-blind-except==0.1.1 flake8-debugger==4.0.0 flake8-deprecated==1.3 -flake8-docstrings==1.5.0 +flake8-docstrings==1.6.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.4 From 2e01f181367754e9e0a34612093876d3d8487fe5 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 2 Apr 2021 00:18:35 +0000 Subject: [PATCH 775/980] chore(deps-dev): bump flake8-blind-except from 0.1.1 to 0.2.0 Bumps [flake8-blind-except](https://github.com/elijahandrews/flake8-blind-except) from 0.1.1 to 0.2.0. - [Release notes](https://github.com/elijahandrews/flake8-blind-except/releases) - [Commits](https://github.com/elijahandrews/flake8-blind-except/compare/v0.1.1...v0.2.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 84bde1e27..c83705423 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,7 @@ black==20.8b1 coverage==5.3.1 factory-boy==3.2.0 flake8==3.8.4 -flake8-blind-except==0.1.1 +flake8-blind-except==0.2.0 flake8-debugger==4.0.0 flake8-deprecated==1.3 flake8-docstrings==1.5.0 From 1aac0254a3ff46e12cc547c93bab6b048ecec227 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 2 Apr 2021 00:19:07 +0000 Subject: [PATCH 776/980] chore(deps-dev): bump ipdb from 0.13.4 to 0.13.7 Bumps [ipdb](https://github.com/gotcha/ipdb) from 0.13.4 to 0.13.7. - [Release notes](https://github.com/gotcha/ipdb/releases) - [Changelog](https://github.com/gotcha/ipdb/blob/master/HISTORY.txt) - [Commits](https://github.com/gotcha/ipdb/compare/0.13.4...0.13.7) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 84bde1e27..e15b021b6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,7 +9,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.5.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 -ipdb==0.13.4 +ipdb==0.13.7 isort==5.6.4 pdbpp==0.10.2 pytest==6.2.2 From 4140f0610bbc5d117d47a7da2e90cd6dca8942dd Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 2 Apr 2021 00:19:24 +0000 Subject: [PATCH 777/980] chore(deps-dev): bump pytest-randomly from 3.5.0 to 3.6.0 Bumps [pytest-randomly](https://github.com/pytest-dev/pytest-randomly) from 3.5.0 to 3.6.0. - [Release notes](https://github.com/pytest-dev/pytest-randomly/releases) - [Changelog](https://github.com/pytest-dev/pytest-randomly/blob/main/HISTORY.rst) - [Commits](https://github.com/pytest-dev/pytest-randomly/compare/3.5.0...3.6.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 84bde1e27..86c7ac093 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,6 +19,6 @@ pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 pytest-mock==3.5.1 -pytest-randomly==3.5.0 +pytest-randomly==3.6.0 requests-mock==1.8.0 snapshottest==0.6.0 From ee82f44d6793a5f6616045de92c85ef0203ea80f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 6 Apr 2021 11:17:27 +0200 Subject: [PATCH 778/980] chore(deps-dev): bump flake8 from 3.8.4 to 3.9.0 (#699) Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.8.4 to 3.9.0. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.8.4...3.9.0) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> Co-authored-by: Stefan Borer --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 23f152a86..c35ed0f6d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==20.8b1 coverage==5.5 factory-boy==3.2.0 -flake8==3.8.4 +flake8==3.9.0 flake8-blind-except==0.2.0 flake8-debugger==4.0.0 flake8-deprecated==1.3 From 0db74e4c4f4222163c6d7b4c783d2e608e6ae984 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 7 Apr 2021 00:14:45 +0000 Subject: [PATCH 779/980] chore(deps-dev): bump pytest from 6.2.2 to 6.2.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.2 to 6.2.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.2.2...6.2.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c35ed0f6d..edec985e3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.3.0 ipdb==0.13.7 isort==5.6.4 pdbpp==0.10.2 -pytest==6.2.2 +pytest==6.2.3 pytest-cov==2.11.1 pytest-django==4.1.0 pytest-env==0.6.2 From 918e7c178421d6c96ba0c6e9d2c9b4b471d7b87e Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 7 Apr 2021 08:15:55 +0000 Subject: [PATCH 780/980] chore(deps-dev): bump isort from 5.6.4 to 5.8.0 Bumps [isort](https://github.com/pycqa/isort) from 5.6.4 to 5.8.0. - [Release notes](https://github.com/pycqa/isort/releases) - [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md) - [Commits](https://github.com/pycqa/isort/compare/5.6.4...5.8.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index edec985e3..45d4d9a7e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ flake8-docstrings==1.6.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 ipdb==0.13.7 -isort==5.6.4 +isort==5.8.0 pdbpp==0.10.2 pytest==6.2.3 pytest-cov==2.11.1 From d47bc97cf1ff329a44a40f95cab0e4331af1ea0e Mon Sep 17 00:00:00 2001 From: Stefan Date: Wed, 7 Apr 2021 10:41:41 +0200 Subject: [PATCH 781/980] chore: dependabot config, auto-approve action --- .dependabot/config.yml | 17 +++++++++++++++++ .github/workflows/auto-approve-dependabot.yml | 13 +++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .dependabot/config.yml create mode 100644 .github/workflows/auto-approve-dependabot.yml diff --git a/.dependabot/config.yml b/.dependabot/config.yml new file mode 100644 index 000000000..25205d748 --- /dev/null +++ b/.dependabot/config.yml @@ -0,0 +1,17 @@ +version: 1 +update_configs: + - package_manager: "python" + directory: "/" + update_schedule: "live" + automerged_updates: + - match: + dependency_type: "development" + commit_message: + prefix: "chore" + include_scope: true + - package_manager: "docker" + directory: "/" + update_schedule: "daily" + commit_message: + prefix: "chore" + include_scope: true diff --git a/.github/workflows/auto-approve-dependabot.yml b/.github/workflows/auto-approve-dependabot.yml new file mode 100644 index 000000000..70b753774 --- /dev/null +++ b/.github/workflows/auto-approve-dependabot.yml @@ -0,0 +1,13 @@ +name: Auto approve dependabot + +on: + pull_request_target + +jobs: + auto-approve: + runs-on: ubuntu-latest + steps: + - uses: hmarr/auto-approve-action@v2 + if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]' + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" From 2f12f86d6132c1362d7065ad0fd8cf89a4f4f377 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 9 Apr 2021 13:49:17 +0200 Subject: [PATCH 782/980] feat: add customer_visible field to project serializer --- timed/projects/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 35f895ccc..d4a900c31 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -76,6 +76,7 @@ class Meta: "billing_type", "cost_center", "reviewers", + "customer_visible", ] From 4184b76c66b5233d7a568cc6e37d9112ae9d939f Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 7 Apr 2021 16:21:01 +0200 Subject: [PATCH 783/980] fix: add billable column and calculate not billable time --- timed/reports/templates/workreport.ots | Bin 12793 -> 13379 bytes timed/reports/tests/test_work_report.py | 15 ++++++++++++++- timed/reports/views.py | 18 ++++++++++++++---- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index a205f11d5760930d0006f37b93dbed2ecde6026a..4e37c50e562d344c3dc7928df4d9d013fa73403e 100644 GIT binary patch delta 11940 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 J+nE0J{vT5kZy*2w literal 12793 zcmb7q1z23mvNaCD-6g@@-Q6v?yE_bSL4t(f?oM!b5AGV=-6goggKNn-@0|PozvkOB zz4y#ov%71itGZUpOM!x+0s%n*0pWT@$#}%)$g%+e0lgismw>Fzt&JVs?2Ps8?5xZU z^&QP^ZRnhBjA(830p zR5GUXw+KK$ZwKZpxssWawSkSkxfOuU@!w5aI~&t4^0H!Z(AdzgAAyq)7gl(E-F!V> zpg>=rnT4a$fq+1PBCh|NNT%0_|1 zL5jgkg-b?CMoP^}#mLD13wuVe-+1cEj?u&Q;nSiRozony>qpLD^0zV%>z@- z!wb!0>#dXP-%H}VDpLEa(|c-iI%^Ain#u<1vPT+nhFVMdS}Vufin=;lMmlSTx>{zs z%6@cL{}^bR@2y+2n!9GV>NnwaXDSR9_18v3y?J<>h@V_<1% zv1@9xbLOCLe!FjOe_&>Fcz$be`CxW=b9r-aWbI&N<9cCZZ+`!3_2<>*boctq;Pzbi z*4)tkZ1>@O@8ROW`oiSK%EI32^x@jb{^s1p#@PAZ{Po$&+S=O2&cW{9_U8Wa!OrIK z{?_5);pWlB`pNyl@!9^x(cZ<~#qsvd&)uuDqr3C%yUW9;+nuNT!?Uxq%j>(#yT`lh z^M||3=jZ1O57HPQAlPaNVF4xAh2v#7c@$NAAiIU;YmLJiQxWT9$Z-a=kV~mj0vzUV+;O5DO5^x`vF(UyaZV6a`;)Xcgn z+dr>7+ay}H#+8k};pn+-PAG+iQA}+ybavKIOPpXL_4bVAqVrsU+Wh`iUmx3*TK`hW zr5p;K=&66E#pssYNb{xDc(n3U8d}aL*C9aiW8&A%cugF+K&z}F9G&znf*dSNOQisX z!T#E;wr|bLW25=1ka8UOP+u@VB{W~v+l(dF@7ebjP8@W8;?5uV>U)DCMv?}*@4!o( zbQk+ci|46;^b_))pei4@b^E@z3TSc0JM7I3;_9o3DxWi*r03DJ;`FKzUeiJQY$2l+ zuLPE@r4FtGQ(iZHEzyh9x?R3;-ot%u7dA>Vg@^v`icdT>$Xm|lN?iHl*l|^2-xQb(2(%QxV zSa};$Mk%;n4l`jQ?1^Ae=4_-evzBjLELgCCDR>CW>D%ILRTJb+T*OmyWFHM!vB6oR z7L(xiiP==G&}g>X#e&1jvgVAX!)QSBVbSqY5IUFAWS_qV>_gAQZ6=s=r5YgO8m8`x z-QO*p=&&7zo(PoDRMO9MV8XTJJvsQ1Z-jINrpAjqzJ{miR(DO#ftGcc7|0C)@PQp0@t>%QWw6WK7M<< zzncRDMZmFy4i!&l*CgLQV&~Elrdho5yk88uE}m#a@ffuU@^-K+LfsNf8Ci+kAq?1% zqeo$LO&w?weD&_pi?$BC(o5Ko9?N6idJ0LnOAoX;%nyLE18enu+QQ=^--_j%q_9JN ziJ;U1H}6dRX2NB*P`TU8*ShNZGFmHU6337$TP2$6g?Wp|hnRZX`~Ve71g#VYD zOE^e?Jl}*~Rv|>3c591EgG`2|2HCa5*B!qO)>{y3K0Hb)Fqm+k_cimdsVq>>S{&wp zt&nOV72;Q(v6ED?Xk)U_o;0`q8K0x2%^bPmkvG1BieLvN5M^*I5BvW3-g!W(qA+lG zSKu@WRc#A?rHRv#psIWJaLt*~9{#dMJ7jyZDcsaf-&zKG1RUlLI~Tl?L53I!Z}1#N zI6i1Gi-9le_RFehP+D3qvjpcU zq{f(=Gd8$Q!Q>WgoT^P;UfiOi`scPf690J0IV0(~VAs<=G�`a4O2s%APMcTZh5^ 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

s0BQ3_`3QRs zdM;7{)1=VaFa;lIE5r=v98#t{N_QQ=2Pfbd5{$4rzG?ybUjbI1CAh9#yBfVsm`gg9d))4v_iZHI2@afrgn9k6mxC zod-2^bch~$&(8e@8ai0n20D^P4N(m-Du%NKrehCu6L_cc6d|0{xp{7|q>CePY3mVi zAQ_utGYI&+6&~@?LBn8Dw(AOSMv!jg&pXn$a`kSl1LT*@;PI77%Z% zuqZ!&LE|Sk5~KiX?K(C5fFo{3BbZ=d({W7oOfDTyyBZG;Ci@jC>3s_ENLtdfBY&(@ zg9tx!F6kPGjmIACtqbPez+GX))Ggm0+dg|$<=$4_gCLCur|d>VCd`PNn+lS zH6KR4I22(@rCpO$HRRcVA|h;noQTl-Z%)G=r8CoSi{$#lzd&P6aDe0zpvZOWiK)87 ziiQEvuMGg1jnf-M=J#>BdliTw>4dTy>G}Zm)%QE=I$v6fZD?krXvdcRefZGUs* z%xHy07w+{iT{-flTe;bt`9g3$5RvJprt1!GA!wuAnIO^m)!qP4u~_)>bdx`MTo~U1 z=?NdZ@8Ze2XaR)Io|ugpgiomMV?3ojghqW7v5T(b%5=`On;?pZb~|yzCp7t!^TyFvlu3&Xoxa|;cqoW3=CG^7+KqskL?K=Mo%+}nb>gm zbK(V3?dkm1_YCN9_hy7x&a46C*5yllI7O>XQ&;X{1mqgFQ_P}u2H|2Ojc0mhtyu=x zy3y`G>JOJH{nIrW&Yl#fTsr#>{g-OU+KreakDuA-O24)*OVk_J%X=296npe(U0=>* z4vVxQ3|Q6>@D8sTMX`#!>rB{^(F?(58WBQ4Gl(Lj7dQZc5#b9+DkY|VbW`02MjJs zikhoOE~VT4!%qWB`I&`1XAm$+t?qZrpXdhFUnCpv2;L_8p2juO@>h**mhivDNwn9A zUPeSofJQ=Al(gVZd23Z1vX+U+D7m0- zD;IYf&bh>6{{5r?I$Wdh=pZN^aCKbQt<~MTJ;zcLAWMg+I$+rtn}a*zT%j{by|gyW z4hlg=At^Qd8dl_6X?@3qvSdv#Yw8*&?lfDVCbdOT-=Oe{X03V>AI9t+z~}H_%+e?# zL@sxcKD==^$7Dm0taD%>O)!j7{`Xezemu)Bx{H0o_Z6fy)bf{cWZfF9yl9xE5({?Vx)Z_vD%@abld{|Ns6cH5_jzuCXHVE-BQ=iSu5Alk3ZzuQ~=XO>?mAfPvK@0Wc={NIw@@0#l$`|u0*&%NEh z&b$7C=a&Zi_bh+Bo4dbY`CXI!d!9E{_Lngr{qJ7=QKS8PrZ<)Lms$P=)Bn*htf(vWzs*ReqT$%zs1zk^V93{xkCTiR5kK{$=}Ae>08COM$ Date: Wed, 7 Apr 2021 14:46:52 +0200 Subject: [PATCH 784/980] feat: show not_billable and review attributes for reports in weekly report --- timed/redmine/templates/redmine/weekly_report.txt | 2 +- timed/redmine/tests/test_redmine_report.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/timed/redmine/templates/redmine/weekly_report.txt index fd06522f5..129d72e63 100644 --- a/timed/redmine/templates/redmine/weekly_report.txt +++ b/timed/redmine/templates/redmine/weekly_report.txt @@ -9,5 +9,5 @@ Estimated hours: {{estimated_hours}} Reported in last {{last_days}} days: {% for report in reports %} -{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment}}{% endfor %} +{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment}} Not Billable: {{report.not_billable|ljust:"10"}} Needs Review: {{report.review}}{% endfor %} ``` diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 68a14b853..0cd453d0e 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -35,7 +35,8 @@ def test_redmine_report(db, freezer, mocker): assert "Total hours: {0}".format(report_hours) in issue.notes assert "Estimated hours: {0}".format(estimated_hours) in issue.notes assert "Hours in last 7 days: {0}\n".format(report_hours) in issue.notes - assert "{0}\n".format(report.comment) in issue.notes + assert "{0}".format(report.comment) in issue.notes + assert "{0}\n".format(report.review) in issue.notes assert ( "{0}\n\n".format(report.comment) not in issue.notes ), "Only one new line after report line" From b92799d66759479827cf11f958c12d55d9c8d5bd Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 7 Apr 2021 10:04:35 +0200 Subject: [PATCH 785/980] fix: add custom forms for supervisor and supervisee inlines labels of foreign keys for the supervisor and supervisee inlines needed to be changed --- timed/employment/admin.py | 36 ++++++++- timed/locale/en/LC_MESSAGES/django.po | 103 ++++++++++++++++++-------- 2 files changed, 107 insertions(+), 32 deletions(-) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 0ef58780d..2f9e391aa 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -16,7 +16,38 @@ admin.site.disable_action("delete_selected") +class SupervisorForm(forms.ModelForm): + """Custom form for the supervisor admin.""" + + # Change the label of the supervisor through table attribute to_user + to_user = forms.ModelChoiceField( + queryset=models.User.objects.all(), label=_("supervised by") + ) + + class Meta: + """Meta information for the supervisor form.""" + + fields = "__all__" + model = models.User.supervisors.through + + +class SuperviseeForm(forms.ModelForm): + """Custom form for the supervisee admin.""" + + # Change the label of the supervisor through table attribute from_user + from_user = forms.ModelChoiceField( + queryset=models.User.objects.all(), label=_("supervising") + ) + + class Meta: + """Meta information for the supervisee form.""" + + fields = "__all__" + model = models.User.supervisors.through + + class SupervisorInline(admin.TabularInline): + form = SupervisorForm model = models.User.supervisors.through extra = 0 fk_name = "from_user" @@ -25,11 +56,12 @@ class SupervisorInline(admin.TabularInline): class SuperviseeInline(admin.TabularInline): + form = SuperviseeForm model = models.User.supervisors.through extra = 0 fk_name = "to_user" - verbose_name = _("Supervisee") - verbose_name_plural = _("Supervisees") + verbose_name = _("Employee") + verbose_name_plural = _("Employees") class EmploymentForm(forms.ModelForm): diff --git a/timed/locale/en/LC_MESSAGES/django.po b/timed/locale/en/LC_MESSAGES/django.po index e016f9c49..5bee27d66 100644 --- a/timed/locale/en/LC_MESSAGES/django.po +++ b/timed/locale/en/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-04-14 17:04+0200\n" +"POT-Creation-Date: 2021-04-08 14:55+0200\n" "PO-Revision-Date: 2017-03-02 13:59+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -17,55 +17,63 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 1.8.11\n" -#: timed/employment/admin.py:23 +#: timed/employment/admin.py:22 +msgid "supervised by" +msgstr "" + +#: timed/employment/admin.py:34 +msgid "supervising" +msgstr "" + +#: timed/employment/admin.py:47 msgid "Supervisor" msgstr "" -#: timed/employment/admin.py:24 +#: timed/employment/admin.py:48 msgid "Supervisors" msgstr "" -#: timed/employment/admin.py:31 -msgid "Supervisee" +#: timed/employment/admin.py:56 +msgid "Employee" msgstr "" -#: timed/employment/admin.py:32 -msgid "Supervisees" +#: timed/employment/admin.py:57 +msgid "Employees" msgstr "" -#: timed/employment/admin.py:38 +#: timed/employment/admin.py:63 msgid "Worktime per day in hours" msgstr "" -#: timed/employment/admin.py:58 timed/employment/serializers.py:248 +#: timed/employment/admin.py:83 timed/employment/serializers.py:248 msgid "The end date must be after the start date" msgstr "The end date must be after the start date" -#: timed/employment/admin.py:68 timed/employment/serializers.py:262 +#: timed/employment/admin.py:93 timed/employment/serializers.py:262 msgid "A user can't have multiple employments at the same time" msgstr "A user can't have multiple employments at the same time" -#: timed/employment/admin.py:88 timed/subscription/admin.py:14 +#: timed/employment/admin.py:113 timed/subscription/admin.py:14 msgid "Duration in hours" msgstr "" -#: timed/employment/admin.py:124 +#: timed/employment/admin.py:149 msgid "Extra fields" msgstr "" -#: timed/employment/admin.py:129 +#: timed/employment/admin.py:154 msgid "Disable selected users" msgstr "" -#: timed/employment/admin.py:134 +#: timed/employment/admin.py:159 msgid "Enable selected users" msgstr "" -#: timed/employment/admin.py:139 +#: timed/employment/admin.py:164 msgid "Disable staff status of selected users" msgstr "" -#: timed/employment/admin.py:144 +#: timed/employment/admin.py:169 msgid "Enable staff status of selected users" msgstr "" @@ -73,24 +81,24 @@ msgstr "" msgid "last name" msgstr "" -#: timed/employment/views.py:87 timed/employment/views.py:99 +#: timed/employment/views.py:96 timed/employment/views.py:108 #, python-format msgid "Transfer %(year)s" msgstr "" -#: timed/employment/views.py:138 timed/employment/views.py:197 +#: timed/employment/views.py:147 timed/employment/views.py:206 msgid "Date is invalid" msgstr "" -#: timed/employment/views.py:141 timed/employment/views.py:199 +#: timed/employment/views.py:150 timed/employment/views.py:208 msgid "Date filter needs to be set" msgstr "" -#: timed/employment/views.py:225 +#: timed/employment/views.py:234 msgid "User filter needs to be set" msgstr "" -#: timed/employment/views.py:233 +#: timed/employment/views.py:242 msgid "User is invalid" msgstr "" @@ -142,6 +150,37 @@ msgstr "" msgid "password" msgstr "" +#: timed/templates/login.html:9 +msgid "Please correct the error below." +msgstr "" + +#: timed/templates/login.html:9 +msgid "Please correct the errors below." +msgstr "" + +#: timed/templates/login.html:25 +#, python-format +msgid "" +"You are authenticated as %(username)s, but are not authorized to access this " +"page. Would you like to login to a different account?" +msgstr "" + +#: timed/templates/login.html:46 +msgid "Forgotten your password or username?" +msgstr "" + +#: timed/templates/login.html:50 +msgid "Log in" +msgstr "" + +#: timed/templates/login.html:57 +msgid "Current user:" +msgstr "" + +#: timed/templates/login.html:63 +msgid "Login with SSO" +msgstr "" + #: timed/tracking/serializers.py:47 #, fuzzy #| msgid "A user can only have one active employment" @@ -169,39 +208,43 @@ msgstr "" msgid "Report can't both be set as `review` and `verified`." msgstr "" -#: timed/tracking/serializers.py:294 +#: timed/tracking/serializers.py:151 +msgid "Only reviewers may bill reports." +msgstr "" + +#: timed/tracking/serializers.py:307 msgid "Only owner may change date" msgstr "" -#: timed/tracking/serializers.py:304 +#: timed/tracking/serializers.py:317 msgid "Only owner may change absence type" msgstr "" -#: timed/tracking/serializers.py:322 +#: timed/tracking/serializers.py:335 msgid "You can't create an absence on an unemployed day." msgstr "" -#: timed/tracking/serializers.py:328 +#: timed/tracking/serializers.py:341 msgid "You can't create an absence on a public holiday" msgstr "" -#: timed/tracking/serializers.py:332 +#: timed/tracking/serializers.py:345 msgid "You can't create an absence on a weekend" msgstr "" -#: timed/tracking/views.py:176 +#: timed/tracking/views.py:177 msgid "Editable filter needs to be set for bulk update" msgstr "" -#: timed/tracking/views.py:185 +#: timed/tracking/views.py:186 timed/tracking/views.py:206 msgid "Reviewer filter needs to be set to verifying user" msgstr "" -#: timed/tracking/views.py:196 +#: timed/tracking/views.py:197 msgid "Reports can't both be set as `review` and `verified`." msgstr "" -#: timed/tracking/views.py:244 +#: timed/tracking/views.py:257 #, python-brace-format msgid "Your request exceeds the maximum allowed entries ({0} > {1})" msgstr "" From 7a87d935893dbc68fd59a4fb477691ad209b6a3b Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 9 Apr 2021 15:26:35 +0200 Subject: [PATCH 786/980] fix: translate work report to English --- timed/reports/templates/workreport.ots | Bin 13379 -> 13246 bytes timed/reports/views.py | 6 +++--- timed/reports/workreport.ots | Bin 12793 -> 13246 bytes 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index 4e37c50e562d344c3dc7928df4d9d013fa73403e..50a5aa3e1d6173fc091daf541bb7fd543d6255b5 100644 GIT binary patch delta 11087 zcmZ{~1#n$GvMp>#%*@Qp%n(D&%*+fiGaN(A7_(z$W;kYwnemvJIcBzBzRb*j=l=Dc zYO7XvtEH-4(w2I+)-S6rOGG7E$WItxU@%}{7`72V5OpB`fn3rbQE2~?*#Ch-D!;mi2!%|FLoY?b-;g_7~wHxc}l;2)=)?AqYYM{I8)G5+5)Y zyGnu)`rro_H3@=3|Jgr$MWgS^rP?%GsN(ShlA4JT9R4>C`Q7%Z?Ab_k{n6y%@2s(p z;G35lY00Urj(UsW+3&e9(X}e+Ho^X9UH!JMhQ7n+SEZjEt-rcJpzol6XFHLJIZRGe zahR?_v59qBZI*7As;pq4Gz+9>7*hfaPQk6j9jVM zY713z4q8@?=jhJOGK~Fpt;(~0sw zk{z$Up*THtv1R6)I0F}T_JU2ea}fwpJuXB``Ah6GgO-d2s7?vYirdA%z%nPo9gyLIsKsHqt)Odm7xvd zzNw*GT9W$wAn;81)o1Wj-nKXmF_3 z(%|=t`sp-`+`G&%CTX7|?u^+_+hjuy7nqyRVUYkOS!ft}DBkrha4@hyNHDN}Eh-oo zn12?P3fO-YxIeOstCyX*3zMh4-Gc6_^BNbLA7Dze_DDxlrHQ~mBBj5Mqr5-XMSHc$ z+$(ViLp+Fpf}$5fBqnL-E&Hw9cZBOpkcV^W4~xBMwFDSf?lwikX_B(2igupaZ>(c5 z@=R_%#>aRH0w;+S9rGVIPgzdYhE?5dxguW#IDwTcO!LhggsbAR_ird?3z1+T1E&>V*v+j@GDf8`r-+~p}scl)wZL{k*>dXtuocd z>1G2a7Y>=Cq61b#MeC zJDIJab@(Y;e?1o0%1j$@BMg=W_#-gAY4qg{R}@5ssK-KcD87;j!+Sk44Md02VgRI@ z)n!GwX{qFE8C9p%x+r*5u+$30#)6c{ClL}H$F16I9D#mxkB2Yn)u64zBMbj>sU0eh zgdy&Mv?s$%xWn&`!UbUBY~B1Xs}%ojRd( zXh1bt(sy1sW6+RuPq0ouF2L&XK(dp*FCbG=n%k8!CZRy;eXTTICHJT2O&znwEjnsf z29_SGy_BXa=iQv-aS{0ABKClV$^|WBPST9|F(Fx1N2D~_!wAZwFB)35h;#xx;&7#z zC$Fg^SPQYvsCy$%RY{FC3F$A2)JCzpRFPi;mXCd!<_pf%SiGS6&9VmfES(7?aM`yW2kC0&k{ImW={n77iG|rD73DdREy40 z(S@;-t$Bwav~z@(#QXQA^SPwcJ1yl3Sb~oTO=JgFSngaz@#+sFaezzZrky!)COL`q zWeiq`#brp_HDD=P)NCYfqv_4J=l(kz4Wr-c*p*+UM{$)$_K5dk00;nOPn0Zk&|=49 zzT0UgTU6|Van>cWL2ObW!){Z%^+!rbjKhqiJnmR9jPerZ;i%de!qo8Yc)6D%d<3chn3s!mUiMlJ zM+kBfBU9YG`IHoq(<6zUCbbF4h9HU?05yYUyy=7Vb+;# z5;en*0AawipIRXu$H6Egs$_R8Zk=nz+yifKy zS~Nd+R2T#}1Z|ciV(}>LUa~ldz*ixkS1m*Ad0|(tmujD`**D`Ac`~|kZc1%f=7Wek zRFk7dyNPvG;T-Sb-?(b8DRT}QeZe?phEgnV0n}}stWK&CGdqF;kd%3jfwp&7to&1v zW3tyd&W{M2$T6?*pUox2THG6g5pz@^;JjVq>(~!yn`sVyx1hq?^2s~9|H2~2#iJG zm?wvq7p4S@&d%SYSx6wvuZh07cjpsf04kaH_+7ej_c~LYf0=`Y z+uq#XA9t5JpFYMzk8Qe^S<(_{)>)N_8Prm2FbPqBQM9qR|FXFsrnsN6g8t2s@0*e) zLYT=5CY<0r^guxCd)s3aIuWOI)Tx7aK%<5o;>i4|6=`-#oIC3@zPfm*?=#Gk6@a(d zI14AGqK0uSX){ILh7@M;V?L?x>f20^R7A636@l%3OO)#2xYHRW2Yqe7jK8J?@>k2% zE^652w;6rG(lY{OdmU!p07?%z)7Bak6Qkhsz) z>>qmhQzm(zvlNeEb}BC218W3DL{X8j+twRg5kryOm5&&24T-zCi=RCfKU(@KFrBy7 z1}3QjNZAPp*xtJbTgCX|U<7M42FQA0woG<0apob>{dk*adR?9UBkpoz3&f0_6^Wi zgnT2a2hr?S1fz3tCcqzogoHv+ccZ#4Nfn4n#ZZLxUfQ0P%V;%rFH~7oLo(?C9Us^> z9>g`(exv-TP6%bfMXA5t7IjwYP+8kY>#44ZVBpl{e;`L4jB6jxEYcPW>qnziYW;A= zK*%yEDZtIKdl|-p+j95IZ0n#&+Zcd$hIU|=Cl0apHJ+pw!51ll9cM}ZO58Yp%gP@` zY1n6rKTL|uK*LtU6TQtTy%dFc>EJW$zBrE!`YPLJ`C`nVE?q@>yHAak30q8UdcPMv z+VvQRrh4lAy`^>pJX&B=;^*C2YLcedqAOYj@sxCfT0T4PO)5KY=Lb0tLAnDhWb%`K z4OxDYGFiy{E(!~7S5*q3dc&TRM4WSZZEU!hzYkM_B%*w-f~$%hgbO`-F13f_i*`ps z?`YyxZgiA!b!%MWVz_ba%2;Q9(K5YQGuI%3koI%ACo%^$8q+a%TXd*oQPauO{FlzF zK8|tTCMi+HY{UvA3@=Mpdr=_({Q3d@Er+DvSwqCB?&)Ucf1y5kFDp#_lOjHkI&!cPvf8p=6d8;K4XI;mtPz63C>i^ zT{z!~+lj01!EB@iT>TILlzYk`RFhtV>={*{)%F%C1vlLWB?P zV%4R?mJ&~eVtbL^>t6oZFqwfTCfP_(9aCZbD7qOx+p@Xgd%=Dx zENIp{P+%e1s79%>*AcxbQ+b1$s3S4ouL!PF^9;yl6C7Tbn9h5#-ANoQy_(`O9>GE+ z2F&`XEC%+Yhf-aBgyG~gHE73e$x}GAG|XxSvWQW9W32eHno@)vkzjk3*#qeyaSQ>4 zxdXL}P=GAz@JqiDFs+}4$mfa9)+gwABgW-!WzOO>;1@Sf3tSN@N~&-o-kwJ1J47*f zfD3474LEHO%JD-6S9Z*pigw!D`=ri@bNl8>1tBhw7H_C#%;>M1D~3*p;)UMfiRjpfHhzD)s(MtyFq~#cVf( zj#92Oh>mI@w~yX9j%pf#iw?Bbn8K5kD_D}OzK@~&IOgReu5JkvBj|gji6As2vfxpX zj#d_gB|xgt?2#mT5VGW1E98KgXv!$0p9Kjpb1I)4SJMfDTA$dl#x<^Ew#wf z=_CW)I(F|9C15k|;5I+zN2Yv2B%6+i@RQhZBzNq;3u1Tkz4E$;-6jn&>s7vjXvQqGHp3htLM^=L$;rh#$J3X0DwY?)I)~s~;I=zm{4OWj| z@5u1T1S90619ie01Io%4xN=5i)Yz;9;U5;TG-@7@)NODGx$#Ho`6zBQOn1FbXm?ln zW)9?2y23S~uz!y-fBKl@&(U;=eA~!!j!=8b-r8b_!-(OxF{?@_I7xUfBDf3Jb({k{ z3h2J!uFqVHZ28fwyBboWrG@@lj2bveV0~QGxA9>;t!gQ$aqf#+;R_>qvMWKp=tr4Ftb+^e%J<=`ADJ6Zf?rX$x=4!Q>N$xX{Kg z?!bN2=^6*{A;#zrJ@oBb+DxitL0*7xyQEt~yYo$j?un&*+BtXEWwue@!2}AV9v57$ z1o@TZB=&NeWhyUKY?X)EE(*JSy4q%xb}Pj3=uiNrC03hyj6m^3fUe+}jR6L|>Rm!L zp~v}3R68dTRyRWJRj=q!#na9ZGf;Q~(B}2}yJmIuMG+a9_yX{L{!6mp;~X$C<51*~oFE{wS^^ z(`7(go#R>l9_P5V?yuwFla`;sbUCWMOt{8#kWJ3tMq3vNcZ zOU|?qZsJ*o{Y~rkcgTO@B;1&*Nr{U;fzpxu|1VHV48ucs&K4h+jk;Ok*q>amUA?W$(KR((a z;YudyVb3edY$KqhdWXx^z>76Fy%+tyJE)=OODDnB5I6}HqS(n4Bi%#sry78*X-Sfc z$AG~tQ|dtl)SzsM8K8a(fr_^qpsSJ8L^D67hmRh5f*IMB=o&Ns2Je*sRej!2H(iza;cNv2dUb3Ui^z zC4M=ubK%u+*N>;tJb;m4HlROC^r1H;jU_;Eg9?-e1ozz+Ye8L8k>xuU=wWUKzoI$f zD~JmXlkY_=3t62gWDf$T8Aqoh(ZGL`^hmjuLVH*mNi4&PAfA zry-79^a6#G%Z#Hg$1+El)ju_|Blkx78K+5t)!gUTS9*P`l|%B8$qeg>URuVUqWwHY zz%z%9$ks@w5|a!RJ4{F=HE-_bRhZK78#w`kUzmwdKs&uJl}tjogYieu_L%gFdh77TcanrQ$)>gc+c@;TQcjqrIF5NO{*cB-Mu`h{wRDz zKgqLg{3W(tVuZO|LBF3Nu6k7xP$gh>07a6=ITNm2x-V~#JQ!iz4j^n%s2!{4WG01m zk6ea#|A1}LYXXe$&^-a(q*n1Clc*$571pxcz5zd>gHpwiI*Ct)YhldwD22?nN1Al@ zr1XToYwZnV-Rv_6w)RGrU`9FuowmS`WK-3}!E|LXy-zRmRqpMHg_jG!d^o=z1BBf2 z?^~kuiA}j@0TsexP0`zQN6TG=0yZEIAG`32^WAb)Rn7ZHU86Yu)jV?j%cJruGM&Y{ zWfCFp>&+hK0{@0Q0i@fnF>0wsx7u|>9_FeIek>;@Te>o<9F3DQ*wq#zFQD>+)Lqjf zXY!X;C4|)yT3c@z~q24%zV|sQ4@cIzhH0{i>zuD=e z>12|rOKG!t(xDoa-z4W{&0RHYt1y;Dy}M!W)+*RsM?NHtpJU%0*Xfx(UKMNBfwj8I ze)nQrpu^o*-#FDKmpuV@<*un~wA5SVuy z;R?3lcAswawxugK)_RlfHpaDB_a>mXsWBwJzx6hB%Ya8XJz4vPB#S)7=3czG*i9Lg z_~~oF0Ld}e?Y7wm*88vr?33zR2e8i@g<_1n{WFAbPhSG%&_rTrareBn(gDd-a3L2Y zLJOIJi-O<49F!Se(1ZR3Kt5`mfA)nFZ}3@F$+n7I_ai{szhz96t8^e|OmiFKN3$7+ zB>8;AgV>X5#Jgfi6hsa4F;wzATUh3zn20S{J1G=dQ*q{S-dfaK+w)>y=&g^IlfqYPGKc9#4uj;vk$EfRKt}DAb+QmJXoN*C;g$$ zoK6TCQgP7c*=Iazvw;;?vZCpoZ8vb9W#5ZoyOn$8;!)6yV!7!-bSDz!%nLUoB>#?K z2;5dwc({ab{c3e$hyxP0qt#W&E!tdbfc7zlkMoP3r+AT!Jrw4q->RzI%gOP9;Xg+L z?a}e(;b$-~Zb~r1f9Lf7eNm*+Tkg&EfeZ%rPsIUCoMy*M1QL=1AiQ4c2oDd7fR2EMg9ZN?6%i2!3XTvN8XFE93k3lm6BhxC0t1U29rw>ehmS&nhfje= zK!ZlahD*gwi3v-AkM}3_NJU6WPeH&+PQXWrF2F!YO-V&c#!g1XOGd{=$|Oj~%ELiV z#md0W$wJ4+#>flc<{@VKLdYUc%PmbOsLU-O&L*tHAfnDBrX?UM%_X75D{m~IY$3`> zB+f!C#zgX!o%|~=vkWJxJTJ8@KfNd~yP6Q4n2@NHh=8Ujqn0F>ku0~RilCU7sMuFo znXh7sQeR|bWToUZWL0!jWhGS{CPC@?*S;fB1%l zg@r~ZMkJ;KQlf)@{)k9PO$|*c3D2re%qoe_ZA{BAOD%0FO${y1N~q6@sL4-k%8O|! zNy*F0E39fNYiMn%E^KKiudA!82evo0x3>c8I@%iB+uPHs`m*Z>N?L}?yJqUUhTD5u zs(Ke1dd50?`#XM(c8pH73@>(0Z?sNqbovv9;rc_1*RD zwec;$`PA0++|JqB_WtVL$@=NV^3m=7($L=e!s*t;$?nqcgXQbvwb$eMr}MSftBu{= z-L3uOy`#h3y%~NPfik(2)Hwp%5R0 zW{%J{7}bE>i9|kuAZuPxS>_6`GQX;f`xZz=S|nHphoO)}r41eSG`TEYzW%O^q5Hr9 zNcY8N6H7>k6~3T+c%kyJkO<&TM%^fCI>q&@0k0-(xe33Ck-ca&j(ZO{mt3s#u5RAV z^LUn+2ui~4ZlCJGHjV88dzec)b`aw4+P^oc^zk0!%u|H&bd-JnrK>(Sbk|YMF_ppB zRA{r-@qNaeUbCeUw?`=5+)JHhuEnt!V5FDsvR_kN9VX0}&2o%dbZDcVN9&|2lOsTY z2}Ap?PL6aJLu$}+F*`{mHV7Bd+P!cb< z8zJrqDDyT0P3IfBsy`NKWy_S8Yfr2r>5}_JX7wg+rr)PS7L(#nMi`e_Z3KPUC6Yy|4C`)nh3aTU^1(|gtqB~msO>G^_ct4iG0}y`ETh+Dhlw?q) z^edx{a+J<|K2}q9vAut82+;;Wr{XLyg?%aEsfu%KuRaQR*_8TQpWYgLOoaCcZg1~? zuXYkgB^{l)$fO>BWGR7Wpc>8JI&aWwaqJP|;ko_~gw4hh*aV(#QjrAOk|g3t9rGlBD6pGr2KB8g znjwLW+6$BbZnaoN3}t0x{g4z0eiY{a(XhWJxi~Zc@dfJ*TPubB?5?;}??6q$|uvZgy+CE}4$&*9PhJT%E$9}zQ!B~3*VpQog%{=#OR{BOOEz{q)@6_uIH>`}w2)+=Qhq%^#_&ywFCocOgHD}$ z)@UjH6HQ%x$dt}vxEeiV)`TNs+fg9B{+lnz1n_6#yfZ-}gETw1HD$Uwx&n?d`E3F0J>LEpt$Z|-9mBl1)n9iL0+!T2IVvxKro zNiWm^6fNYm7PLi0ptZ?k0is1fQSh7zoXN?se!hfqfgJG$jsZ_%VCEfPkwlrg#{ISA zDUpxDk|Cuj{QSI~tlof%N;5e=8BRD`{r7`mK&tKId_tnk($8_J6r-Q+R4drp7}Uu| zrE)-KlP_+0Vwuj{V++2sD+|}3RxNmBR2GLDtDr|h?ajLU$dkd0loL~3Vq&A!$N`Nw zvWSUC`M(2Gs<(-FiwcV@lCcVA?jJ?NbL<(v_oE6yG#` z^N}+-oK>J_T+CcFkE+(eO&F&fdo1q+|Ji9Hvys9N?R;NO;{~+H8tHa`7jDuM0u0Sw z_)ht8eZohCWLOG3D8VX#|7U;r&sKpXjgPS>BpamZ``JrZmWXEF(CNdfdn!=$qc-qu z&97^1rjM`IVG(b=pia+cA863My5koT;=}auQ2q01c?DVXG9{%p)VsYgNyuN=C$1n` zDcv0OkYRtOeM5bCD;5v%KzZJao}{mzw0ic0ltMftN#NDze!OnGYyX%9&CgF;2e8Y# z+410<UWo#$5&vl>u2ad7)H>sCq@NN4HAUE`W{tuDQn?0@m`c_nRC#ppNb(V zJ^0@t4}_SNeKUL<8eMxW6bk|d3uD?gG~os_`FBNNyfujutryDSy{+Kxju|I#Po@;D{Sq;56?wLm$x|5Br?A(IfMO=qtQ?xilj zfao`I!sckkRZhFAL6^tu@FjyHRP!F!Hd@{YMlLuv`i@$%wm!-0X2fb%*EK3b1NsQ8HhgS=k%t^ z?^@1~azR^7m|djZO;G50d;sGfA5#sAWY21M5fD%)^-X9KhrMeApblitGSq5hktG+} zcEFQX5JXs*D;2|Z*^1lqbWY%#=B?W=B9~v!uFy}L*`7NAnrcjZFD_#`N0pxG%b({g zfmj)8CIX7&7-u+yd{m{D;pUvU8K3O|I$irWF7tHM$uhB^iW)>kb5Xus9X3{oYGTze zfT}C9Af=;3U-GB`;kF}lVh6tHby}J2&&9cvbgsPyLbqRn4ZxeprZb*(Wx*w||MQi- zt1+&QC-ZwDrC$9ICfGUSyU-aFR5Hla)jJhxetmQO!=&-~mg0`QT+)iT8a7vED4_+@ zef+(y0+%lY``BVI>xy$u1FQm*fxV`0LQ)tg>QVu)5yX~rnmP>CwlCgfStIjUwb>Nd zhP$Y_&U9?rUw(4>#YkU@f?}Lpz&(}9#O!YDqeP|o{MD&$mmv+qf^juUTO6c zJ-*I7o;@z{bCBL+7k*pcf()UDB<$uA^|IRhq^~vPX_01aIMpodwOqq_&>kI^^{6Pn zmgE7Di%Yj)adeTZv1l4CLUeVUIz8ySyz!kVFrUKIh2FIJb|kM*q3bkfd3pj6_BKtk zrgYHuU3qQ(?9cL^SXSL;FRlM}y^+W+*VVIdZduNwP*Itt`))GvNZ4?ecvIr`*m&0J zZSlJ*8;(W(WkpG_WClo9>poapt}73fCW-~HY)JT>`#rvKKl5JRw7TBnYDXoE1@ar5 ziKU^!#8P9YchgghXM~-#lGi+>5{vz=qqlIFy_3XV*xV^W@*V0ee!!+#2Jv`uv9pWo+ zxhE}$-*v*Vb3F@lezDhjx0ti!wbgJ{19N)*`FxkV$B6J+gTEMU44gq#Ote*Z$#Lwl zk-aGJGIj$^ylZRnkxF7px4Slc4vHtlX0yDmP_c6gF~Y=ueNR?w)n-6Xb1)VEAT1Zs zCHj=uVr4NZ)8Fz5Y){Xl(L(&DY-;F-850iaE~wFS;iLi`ZXkrqsVNmgVbT9rXX{_=()CbboEJEcl6l zf6JAuDt~R@Y?6ObmQCg_rm=Cu{=)?P%MnamVq?_&NBi%a`G0tU|5Hl;Bpv>TC8#6| z4uJuN0rnq0;eRKp;s2-8ABj0pj~!3@pRxZdbpeL(zkD>X|BP+!>T3PX@-GMRKcoNO lGaLWi)gy6=o%-+lJh9_LR5B*YGhrt(a-cx*F#qHBe*j5gE>!>k delta 11271 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 diff --git a/timed/reports/views.py b/timed/reports/views.py index ff640d617..ac72a57a7 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -227,9 +227,9 @@ def _create_workreport(self, from_date, to_date, today, project, reports, user): hours = report.duration.total_seconds() / 60 / 60 table["B13"] = Cell(hours, style_name=float_style) if report.not_billable: - table["C13"] = Cell("nein", style_name=text_style) + table["C13"] = Cell("no", style_name=text_style) else: - table["C13"] = Cell("ja", style_name=text_style) + table["C13"] = Cell("yes", style_name=text_style) table["D13"] = Cell(report.user.get_full_name(), style_name=text_style) table["E13"] = Cell(report.task.name, style_name=text_style) table["F13"] = Cell(report.comment, style_name=text_style) @@ -271,7 +271,7 @@ def _create_workreport(self, from_date, to_date, today, project, reports, user): # calculate location of total not billable hours as insert rows moved it table[ 13 + len(reports) + len(tasks) + 1, 2 - ].formula = 'of:=SUMIF(C13:C{0};"nein";B13:B{0})'.format( + ].formula = 'of:=SUMIF(C13:C{0};"no";B13:B{0})'.format( str(13 + len(reports) - 1) ) diff --git a/timed/reports/workreport.ots b/timed/reports/workreport.ots index a205f11d5760930d0006f37b93dbed2ecde6026a..50a5aa3e1d6173fc091daf541bb7fd543d6255b5 100644 GIT binary patch literal 13246 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 JSZUvm{vRW8c)9=p literal 12793 zcmb7q1z23mvNaCD-6g@@-Q6v?yE_bSL4t(f?oM!b5AGV=-6goggKNn-@0|PozvkOB zz4y#ov%71itGZUpOM!x+0s%n*0pWT@$#}%)$g%+e0lgismw>Fzt&JVs?2Ps8?5xZU z^&QP^ZRnhBjA(830p zR5GUXw+KK$ZwKZpxssWawSkSkxfOuU@!w5aI~&t4^0H!Z(AdzgAAyq)7gl(E-F!V> zpg>=rnT4a$fq+1PBCh|NNT%0_|1 zL5jgkg-b?CMoP^}#mLD13wuVe-+1cEj?u&Q;nSiRozony>qpLD^0zV%>z@- z!wb!0>#dXP-%H}VDpLEa(|c-iI%^Ain#u<1vPT+nhFVMdS}Vufin=;lMmlSTx>{zs z%6@cL{}^bR@2y+2n!9GV>NnwaXDSR9_18v3y?J<>h@V_<1% zv1@9xbLOCLe!FjOe_&>Fcz$be`CxW=b9r-aWbI&N<9cCZZ+`!3_2<>*boctq;Pzbi z*4)tkZ1>@O@8ROW`oiSK%EI32^x@jb{^s1p#@PAZ{Po$&+S=O2&cW{9_U8Wa!OrIK z{?_5);pWlB`pNyl@!9^x(cZ<~#qsvd&)uuDqr3C%yUW9;+nuNT!?Uxq%j>(#yT`lh z^M||3=jZ1O57HPQAlPaNVF4xAh2v#7c@$NAAiIU;YmLJiQxWT9$Z-a=kV~mj0vzUV+;O5DO5^x`vF(UyaZV6a`;)Xcgn z+dr>7+ay}H#+8k};pn+-PAG+iQA}+ybavKIOPpXL_4bVAqVrsU+Wh`iUmx3*TK`hW zr5p;K=&66E#pssYNb{xDc(n3U8d}aL*C9aiW8&A%cugF+K&z}F9G&znf*dSNOQisX z!T#E;wr|bLW25=1ka8UOP+u@VB{W~v+l(dF@7ebjP8@W8;?5uV>U)DCMv?}*@4!o( zbQk+ci|46;^b_))pei4@b^E@z3TSc0JM7I3;_9o3DxWi*r03DJ;`FKzUeiJQY$2l+ zuLPE@r4FtGQ(iZHEzyh9x?R3;-ot%u7dA>Vg@^v`icdT>$Xm|lN?iHl*l|^2-xQb(2(%QxV zSa};$Mk%;n4l`jQ?1^Ae=4_-evzBjLELgCCDR>CW>D%ILRTJb+T*OmyWFHM!vB6oR z7L(xiiP==G&}g>X#e&1jvgVAX!)QSBVbSqY5IUFAWS_qV>_gAQZ6=s=r5YgO8m8`x z-QO*p=&&7zo(PoDRMO9MV8XTJJvsQ1Z-jINrpAjqzJ{miR(DO#ftGcc7|0C)@PQp0@t>%QWw6WK7M<< zzncRDMZmFy4i!&l*CgLQV&~Elrdho5yk88uE}m#a@ffuU@^-K+LfsNf8Ci+kAq?1% zqeo$LO&w?weD&_pi?$BC(o5Ko9?N6idJ0LnOAoX;%nyLE18enu+QQ=^--_j%q_9JN ziJ;U1H}6dRX2NB*P`TU8*ShNZGFmHU6337$TP2$6g?Wp|hnRZX`~Ve71g#VYD zOE^e?Jl}*~Rv|>3c591EgG`2|2HCa5*B!qO)>{y3K0Hb)Fqm+k_cimdsVq>>S{&wp zt&nOV72;Q(v6ED?Xk)U_o;0`q8K0x2%^bPmkvG1BieLvN5M^*I5BvW3-g!W(qA+lG zSKu@WRc#A?rHRv#psIWJaLt*~9{#dMJ7jyZDcsaf-&zKG1RUlLI~Tl?L53I!Z}1#N zI6i1Gi-9le_RFehP+D3qvjpcU zq{f(=Gd8$Q!Q>WgoT^P;UfiOi`scPf690J0IV0(~VAs<=G�`a4O2s%APMcTZh5^ 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

s0BQ3_`3QRs zdM;7{)1=VaFa;lIE5r=v98#t{N_QQ=2Pfbd5{$4rzG?ybUjbI1CAh9#yBfVsm`gg9d))4v_iZHI2@afrgn9k6mxC zod-2^bch~$&(8e@8ai0n20D^P4N(m-Du%NKrehCu6L_cc6d|0{xp{7|q>CePY3mVi zAQ_utGYI&+6&~@?LBn8Dw(AOSMv!jg&pXn$a`kSl1LT*@;PI77%Z% zuqZ!&LE|Sk5~KiX?K(C5fFo{3BbZ=d({W7oOfDTyyBZG;Ci@jC>3s_ENLtdfBY&(@ zg9tx!F6kPGjmIACtqbPez+GX))Ggm0+dg|$<=$4_gCLCur|d>VCd`PNn+lS zH6KR4I22(@rCpO$HRRcVA|h;noQTl-Z%)G=r8CoSi{$#lzd&P6aDe0zpvZOWiK)87 ziiQEvuMGg1jnf-M=J#>BdliTw>4dTy>G}Zm)%QE=I$v6fZD?krXvdcRefZGUs* z%xHy07w+{iT{-flTe;bt`9g3$5RvJprt1!GA!wuAnIO^m)!qP4u~_)>bdx`MTo~U1 z=?NdZ@8Ze2XaR)Io|ugpgiomMV?3ojghqW7v5T(b%5=`On;?pZb~|yzCp7t!^TyFvlu3&Xoxa|;cqoW3=CG^7+KqskL?K=Mo%+}nb>gm zbK(V3?dkm1_YCN9_hy7x&a46C*5yllI7O>XQ&;X{1mqgFQ_P}u2H|2Ojc0mhtyu=x zy3y`G>JOJH{nIrW&Yl#fTsr#>{g-OU+KreakDuA-O24)*OVk_J%X=296npe(U0=>* z4vVxQ3|Q6>@D8sTMX`#!>rB{^(F?(58WBQ4Gl(Lj7dQZc5#b9+DkY|VbW`02MjJs zikhoOE~VT4!%qWB`I&`1XAm$+t?qZrpXdhFUnCpv2;L_8p2juO@>h**mhivDNwn9A zUPeSofJQ=Al(gVZd23Z1vX+U+D7m0- zD;IYf&bh>6{{5r?I$Wdh=pZN^aCKbQt<~MTJ;zcLAWMg+I$+rtn}a*zT%j{by|gyW z4hlg=At^Qd8dl_6X?@3qvSdv#Yw8*&?lfDVCbdOT-=Oe{X03V>AI9t+z~}H_%+e?# zL@sxcKD==^$7Dm0taD%>O)!j7{`Xezemu)Bx{H0o_Z6fy)bf{cWZfF9yl9xE5({?Vx)Z_vD%@abld{|Ns6cH5_jzuCXHVE-BQ=iSu5Alk3ZzuQ~=XO>?mAfPvK@0Wc={NIw@@0#l$`|u0*&%NEh z&b$7C=a&Zi_bh+Bo4dbY`CXI!d!9E{_Lngr{qJ7=QKS8PrZ<)Lms$P=)Bn*htf(vWzs*ReqT$%zs1zk^V93{xkCTiR5kK{$=}Ae>08COM$ Date: Sat, 10 Apr 2021 17:59:18 +0000 Subject: [PATCH 787/980] chore(deps-dev): bump pytest-django from 4.1.0 to 4.2.0 Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.1.0 to 4.2.0. - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.1.0...v4.2.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 45d4d9a7e..18ab2aa1a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==5.8.0 pdbpp==0.10.2 pytest==6.2.3 pytest-cov==2.11.1 -pytest-django==4.1.0 +pytest-django==4.2.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 From 4afa9ca46abef70730c536c2189f05c3a2beec2f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sat, 10 Apr 2021 18:04:27 +0000 Subject: [PATCH 788/980] chore(deps): bump django from 3.1.7 to 3.2 Bumps [django](https://github.com/django/django) from 3.1.7 to 3.2. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.1.7...3.2) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f820d653f..eef48dbda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.1 -django==3.1.7 +django==3.2 # might remove this once we find out how the jsonapi extras_require work django-filter==2.4.0 django-multiselectfield==0.1.12 From bd478d2cca35697173518b9056c6df91d5e7a466 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sun, 11 Apr 2021 10:23:10 +0000 Subject: [PATCH 789/980] chore(deps-dev): bump pytest-randomly from 3.6.0 to 3.7.0 Bumps [pytest-randomly](https://github.com/pytest-dev/pytest-randomly) from 3.6.0 to 3.7.0. - [Release notes](https://github.com/pytest-dev/pytest-randomly/releases) - [Changelog](https://github.com/pytest-dev/pytest-randomly/blob/main/HISTORY.rst) - [Commits](https://github.com/pytest-dev/pytest-randomly/compare/3.6.0...3.7.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 18ab2aa1a..aec75746e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,6 +19,6 @@ pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 pytest-mock==3.5.1 -pytest-randomly==3.6.0 +pytest-randomly==3.7.0 requests-mock==1.8.0 snapshottest==0.6.0 From 73ff7c9abb02c2d4101f1690b606ee4c12b807a9 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Mon, 12 Apr 2021 15:59:09 +0200 Subject: [PATCH 790/980] Revert "chore(deps): bump django from 3.1.7 to 3.2" --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eef48dbda..f820d653f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.1 -django==3.2 +django==3.1.7 # might remove this once we find out how the jsonapi extras_require work django-filter==2.4.0 django-multiselectfield==0.1.12 From 6ed9cabeeefd2e6945a63b83de1ee85018fb56a5 Mon Sep 17 00:00:00 2001 From: Stefan Date: Tue, 13 Apr 2021 18:21:42 +0200 Subject: [PATCH 791/980] feat: export metrics with django-prometheus --- .github/workflows/test.yml | 1 + Dockerfile | 3 +-- Makefile | 13 +++++++++---- cmd.sh | 5 ++++- docker-compose.yml | 1 - requirements.txt | 1 + timed/settings.py | 8 ++++++-- timed/urls.py | 1 + 8 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3570ea001..acfe4786b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ jobs: - uses: actions/checkout@v2 - name: Build the project run: | + echo "ENV=dev" > .env docker-compose up -d --build backend docker-compose exec -T backend pip install -r requirements-dev.txt - name: Lint the code diff --git a/Dockerfile b/Dockerfile index c4d6429ab..b91f1d317 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,7 @@ RUN pip install --upgrade --no-cache-dir --requirement $REQUIREMENTS --disable-p COPY . /app -RUN mkdir -p /var/www/static \ - && ENV=docker ./manage.py collectstatic --noinput +RUN mkdir -p /var/www/static EXPOSE 80 CMD ./cmd.sh diff --git a/Makefile b/Makefile index 2162259cb..39d675322 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help start test shell loaddata +.PHONY: help start stop test shell flush loaddata .DEFAULT_GOAL := help help: @@ -6,13 +6,18 @@ help: start: ## Start the development server @docker-compose up -d --build - @make loaddata + +stop: ## Stop the development server + @docker-compose stop test: ## Test the project - @docker-compose exec backend sh -c "black --check . && flake8 && pytest --no-cov-on-fail --cov --create-db" + @docker-compose exec backend sh -c "black --check . && flake8 && pytest --no-cov-on-fail --cov" shell: ## Shell into the backend @docker-compose exec backend bash -loaddata: ## Loads test data into the database +flush: ## Flush database contents + @docker-compose exec backend ./manage.py flush --no-input + +loaddata: flush ## Loads test data into the database @docker-compose exec backend ./manage.py loaddata timed/fixtures/test_data.json diff --git a/cmd.sh b/cmd.sh index 9425113eb..74d6a820f 100755 --- a/cmd.sh +++ b/cmd.sh @@ -5,4 +5,7 @@ sed -i \ -e 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' "${UWSGI_INI}" \ -e 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" -wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- ./manage.py migrate --no-input && uwsgi +wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- \ + ./manage.py migrate --no-input && \ + ./manage.py collectstatic --noinput && \ + uwsgi diff --git a/docker-compose.yml b/docker-compose.yml index cf1755ec9..24ca81337 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,6 @@ services: environment: - DJANGO_DATABASE_HOST=db - DJANGO_DATABASE_PORT=5432 - - ENV=docker - STATIC_ROOT=/var/www/static networks: - timed.local diff --git a/requirements.txt b/requirements.txt index f820d653f..b11931f4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ django==3.1.7 # might remove this once we find out how the jsonapi extras_require work django-filter==2.4.0 django-multiselectfield==0.1.12 +django-prometheus==2.1.0 djangorestframework==3.12.4 djangorestframework-jsonapi[django-filter]==3.1.0 mozilla-django-oidc==1.2.4 diff --git a/timed/settings.py b/timed/settings.py index 9367d533c..336fffa15 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -27,7 +27,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): DATABASES = { "default": { "ENGINE": env.str( - "DJANGO_DATABASE_ENGINE", default="django.db.backends.postgresql" + "DJANGO_DATABASE_ENGINE", default="django_prometheus.db.backends.postgresql" ), "NAME": env.str("DJANGO_DATABASE_NAME", default="timed"), "USER": env.str("DJANGO_DATABASE_USER", default="timed"), @@ -61,6 +61,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django_filters", "djmoney", "mozilla_django_oidc", + "django_prometheus", "timed.employment", "timed.projects", "timed.tracking", @@ -70,6 +71,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): ] MIDDLEWARE = [ + "django_prometheus.middleware.PrometheusBeforeMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -77,6 +79,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_prometheus.middleware.PrometheusAfterMiddleware", ] ROOT_URLCONF = "timed.urls" @@ -148,7 +151,8 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): CACHES = { "default": { "BACKEND": env.str( - "CACHE_BACKEND", default="django.core.cache.backends.locmem.LocMemCache" + "CACHE_BACKEND", + default="django_prometheus.cache.backends.locmem.LocMemCache", ), "LOCATION": env.str("CACHE_LOCATION", ""), } diff --git a/timed/urls.py b/timed/urls.py index 8c71b0738..1a236034a 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -11,4 +11,5 @@ re_path(r"^api/v1/", include("timed.reports.urls")), re_path(r"^api/v1/", include("timed.subscription.urls")), re_path(r"^oidc/", include("mozilla_django_oidc.urls")), + re_path(r"^prometheus/", include("django_prometheus.urls")), ] From 8302247d5a6c3f2a851baa153699432ec849dd26 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 16 Apr 2021 04:07:07 +0000 Subject: [PATCH 792/980] chore(deps-dev): bump flake8 from 3.9.0 to 3.9.1 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.9.0 to 3.9.1. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.9.0...3.9.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index aec75746e..6d525c866 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==20.8b1 coverage==5.5 factory-boy==3.2.0 -flake8==3.9.0 +flake8==3.9.1 flake8-blind-except==0.2.0 flake8-debugger==4.0.0 flake8-deprecated==1.3 From 6cbe9d02d671d871189e32c3f592dd8c9116267d Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 16 Apr 2021 10:02:44 +0200 Subject: [PATCH 793/980] chore(release): v1.2.0 --- CHANGELOG.md | 17 +++++++++++++++++ timed/__init__.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 044f17acd..dd4405cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# v1.2.0 (16 April 2021) + +### Feature +* Export metrics with django-prometheus ([`6ed9cab`](https://github.com/adfinis-sygroup/timed-backend/commit/6ed9cabeeefd2e6945a63b83de1ee85018fb56a5)) +* Show not_billable and review attributes for reports in weekly report ([`a02aca4`](https://github.com/adfinis-sygroup/timed-backend/commit/a02aca48ae609f9ac514238be723c056fa60754f)) +* Add customer_visible field to project serializer ([`2f12f86`](https://github.com/adfinis-sygroup/timed-backend/commit/2f12f86d6132c1362d7065ad0fd8cf89a4f4f377)) +* Add billed flag to project and tracking ([`fe41199`](https://github.com/adfinis-sygroup/timed-backend/commit/fe41199527e5ab37f23c715d844805b7d8944d64)) +* **projects:** Add currency fields to task and project ([`7266c34`](https://github.com/adfinis-sygroup/timed-backend/commit/7266c346236e9e0d1c83d9f84b99a4e782256ba4)) + +### Fix +* Translate work report to English ([`7a87d93`](https://github.com/adfinis-sygroup/timed-backend/commit/7a87d935893dbc68fd59a4fb477691ad209b6a3b)) +* Add custom forms for supervisor and supervisee inlines ([`b92799d`](https://github.com/adfinis-sygroup/timed-backend/commit/b92799d66759479827cf11f958c12d55d9c8d5bd)) +* Add billable column and calculate not billable time ([`4184b76`](https://github.com/adfinis-sygroup/timed-backend/commit/4184b76c66b5233d7a568cc6e37d9112ae9d939f)) +* **tracking:** Set billed from project on report ([`d25e64f`](https://github.com/adfinis-sygroup/timed-backend/commit/d25e64fd4c898757acb565996173f460f636c6a6)) +* **tracking:** Update billed if not sent with request ([`62295ba`](https://github.com/adfinis-sygroup/timed-backend/commit/62295bac19f302fa45281a72edb09397e3cbc4c6)) +* Add test data users to keycloak config ([`082ef6e`](https://github.com/adfinis-sygroup/timed-backend/commit/082ef6e14a406a5d3b1a5f286007169689c0cb1b)) + # v1.1.2 (28 October 2020) ### Fix diff --git a/timed/__init__.py b/timed/__init__.py index 72f26f596..c68196d1c 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "1.1.2" +__version__ = "1.2.0" From 20d867c4e35392dbca9b847b1063633c3499bb5a Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 22 Apr 2021 08:38:33 +0200 Subject: [PATCH 794/980] fix(workreport): template layout with correct line-wrapping Remove unused template too. --- timed/reports/templates/workreport.ots | Bin 13246 -> 11634 bytes timed/reports/views.py | 4 ++-- timed/reports/workreport.ots | Bin 13246 -> 0 bytes 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 timed/reports/workreport.ots diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index 50a5aa3e1d6173fc091daf541bb7fd543d6255b5..952604e4e30049e2b7f0ec7f1916183a52ae7077 100644 GIT binary patch delta 9920 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 ShmHwtYBC2c+zH(u_WuBkbhgg` delta 11573 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 JSZUvm{vRW8c)9=p From 401baec626ca724da2b05536e45612bec97cb66f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sat, 24 Apr 2021 22:10:56 +0000 Subject: [PATCH 795/980] chore(deps-dev): bump pytest-mock from 3.5.1 to 3.6.0 Bumps [pytest-mock](https://github.com/pytest-dev/pytest-mock) from 3.5.1 to 3.6.0. - [Release notes](https://github.com/pytest-dev/pytest-mock/releases) - [Changelog](https://github.com/pytest-dev/pytest-mock/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.5.1...v3.6.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 6d525c866..578316459 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,7 @@ pytest-django==4.2.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 -pytest-mock==3.5.1 +pytest-mock==3.6.0 pytest-randomly==3.7.0 requests-mock==1.8.0 snapshottest==0.6.0 From 4c2e2079dbe387f685dc9fd3a79299ae4757ea1d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sun, 25 Apr 2021 23:22:23 +0000 Subject: [PATCH 796/980] chore(deps-dev): bump black from 20.8b1 to 21.4b0 Bumps [black](https://github.com/psf/black) from 20.8b1 to 21.4b0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/master/CHANGES.md) - [Commits](https://github.com/psf/black/commits) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 578316459..31b3645be 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==20.8b1 +black==21.4b0 coverage==5.5 factory-boy==3.2.0 flake8==3.9.1 From 866c80967e6ad919ea4920c9778079cf7bb464ff Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 09:15:51 +0000 Subject: [PATCH 797/980] chore(deps-dev): bump black from 21.4b0 to 21.4b1 Bumps [black](https://github.com/psf/black) from 21.4b0 to 21.4b1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/master/CHANGES.md) - [Commits](https://github.com/psf/black/commits) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 31b3645be..e5f556cbc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.4b0 +black==21.4b1 coverage==5.5 factory-boy==3.2.0 flake8==3.9.1 From 6ff425941307a0386d835187eaad02e26cc718e3 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 20 Apr 2021 09:17:09 +0200 Subject: [PATCH 798/980] feat: assign users to customers, projects and tasks update serializers create new through-tables for assigned users update django-admin for user assignement --- requirements.txt | 1 + timed/projects/admin.py | 24 +++++-- .../migrations/0011_auto_20210419_1459.py | 64 +++++++++++++++++ timed/projects/models.py | 72 +++++++++++++++++++ timed/projects/serializers.py | 10 ++- timed/settings.py | 4 ++ 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 timed/projects/migrations/0011_auto_20210419_1459.py diff --git a/requirements.txt b/requirements.txt index b11931f4a..209348341 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ pytz==2021.1 pyexcel-webio==0.1.4 pyexcel-io==0.6.4 django-excel==0.0.10 +django-nested-inline==0.4.2 pyexcel-ods3==0.6.0 pyexcel-xlsx==0.6.0 pyexcel-ezodf==0.3.4 diff --git a/timed/projects/admin.py b/timed/projects/admin.py index a0a0daad7..a0a227533 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -5,19 +5,34 @@ from django.forms.models import BaseInlineFormSet from django.utils.translation import gettext_lazy as _ +from nested_inline.admin import NestedModelAdmin, NestedStackedInline from timed.forms import DurationInHoursField from timed.projects import models from timed.redmine.admin import RedmineProjectInline from timed.subscription.admin import CustomerPasswordInline +class CustomerAssigneeInline(admin.TabularInline): + model = models.CustomerAssignee + extra = 0 + + +class ProjectAssigneeInline(admin.TabularInline): + model = models.ProjectAssignee + extra = 0 + +class TaskAssigneeInline(NestedStackedInline): + model = models.TaskAssignee + extra = 1 + + @admin.register(models.Customer) class CustomerAdmin(admin.ModelAdmin): """Customer admin view.""" list_display = ["name"] search_fields = ["name"] - inlines = [CustomerPasswordInline] + inlines = [CustomerPasswordInline, CustomerAssigneeInline] def has_delete_permission(self, request, obj=None): return obj and not obj.projects.exists() @@ -68,11 +83,12 @@ def __init__(self, *args, **kwargs): self.extra += len(self.initial) -class TaskInline(admin.TabularInline): +class TaskInline(NestedStackedInline): formset = TaskInlineFormset form = TaskForm model = models.Task extra = 0 + inlines = [TaskAssigneeInline] def has_delete_permission(self, request, obj=None): # for some reason obj is parent object and not task @@ -96,7 +112,7 @@ class ProjectForm(forms.ModelForm): @admin.register(models.Project) -class ProjectAdmin(admin.ModelAdmin): +class ProjectAdmin(NestedModelAdmin): """Project admin view.""" form = ProjectForm @@ -104,7 +120,7 @@ class ProjectAdmin(admin.ModelAdmin): list_filter = ["customer"] search_fields = ["name", "customer__name"] - inlines = [TaskInline, ReviewerInline, RedmineProjectInline] + inlines = [TaskInline, ReviewerInline, RedmineProjectInline, ProjectAssigneeInline] exclude = ("reviewers",) def has_delete_permission(self, request, obj=None): diff --git a/timed/projects/migrations/0011_auto_20210419_1459.py b/timed/projects/migrations/0011_auto_20210419_1459.py new file mode 100644 index 000000000..edab7da61 --- /dev/null +++ b/timed/projects/migrations/0011_auto_20210419_1459.py @@ -0,0 +1,64 @@ +# Generated by Django 3.1.7 on 2021-04-19 12:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0010_project_billed'), + ] + + operations = [ + migrations.CreateModel( + name='TaskAssignee', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_resource', models.BooleanField(default=False)), + ('is_reviewer', models.BooleanField(default=False)), + ('is_manager', models.BooleanField(default=False)), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_assignees', to='projects.task')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_assignees', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ProjectAssignee', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_resource', models.BooleanField(default=False)), + ('is_reviewer', models.BooleanField(default=False)), + ('is_manager', models.BooleanField(default=False)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_assignees', to='projects.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_assignees', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CustomerAssignee', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_resource', models.BooleanField(default=False)), + ('is_reviewer', models.BooleanField(default=False)), + ('is_manager', models.BooleanField(default=False)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customer_assignees', to='projects.customer')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customer_assignees', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='customer', + name='assignees', + field=models.ManyToManyField(related_name='assigned_to_customers', through='projects.CustomerAssignee', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='project', + name='assignees', + field=models.ManyToManyField(related_name='assigned_to_projects', through='projects.ProjectAssignee', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='task', + name='assignees', + field=models.ManyToManyField(related_name='assigned_to_tasks', through='projects.TaskAssignee', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index a6d5ab166..04f0834ea 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -18,6 +18,11 @@ class Customer(models.Model): website = models.URLField(blank=True) comment = models.TextField(blank=True) archived = models.BooleanField(default=False) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through="CustomerAssignee", + related_name="assigned_to_customers", + ) def __str__(self): """Represent the model as a string. @@ -97,6 +102,11 @@ class Project(models.Model): amount_invoiced = MoneyField( max_digits=10, decimal_places=2, default_currency="CHF", blank=True, null=True ) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through="ProjectAssignee", + related_name="assigned_to_projects", + ) def __str__(self): """Represent the model as a string. @@ -137,6 +147,11 @@ class Task(models.Model): amount_invoiced = MoneyField( max_digits=10, decimal_places=2, default_currency="CHF", blank=True, null=True ) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through="TaskAssignee", + related_name="assigned_to_tasks", + ) def __str__(self): """Represent the model as a string. @@ -171,3 +186,60 @@ def __str__(self): class Meta: ordering = ["name"] + + +class CustomerAssignee(models.Model): + """Customer assignee model. + + Customer assignee is an employee that is assigned to a specific customer. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="customer_assignees", + ) + customer = models.ForeignKey( + "projects.Customer", on_delete=models.CASCADE, related_name="customer_assignees" + ) + is_resource = models.BooleanField(default=False) + is_reviewer = models.BooleanField(default=False) + is_manager = models.BooleanField(default=False) + + +class ProjectAssignee(models.Model): + """Project assignee model. + + Project assignee is an employee that is assigned to a specific project. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="project_assignees", + ) + project = models.ForeignKey( + "projects.Project", on_delete=models.CASCADE, related_name="project_assignees" + ) + is_resource = models.BooleanField(default=False) + is_reviewer = models.BooleanField(default=False) + is_manager = models.BooleanField(default=False) + + +class TaskAssignee(models.Model): + """Task assignee model. + + Task assignee is an employee that is assigned to a specific task. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="task_assignees", + ) + task = models.ForeignKey( + "projects.Task", on_delete=models.CASCADE, related_name="task_assignees" + ) + is_resource = models.BooleanField(default=False) + is_reviewer = models.BooleanField(default=False) + is_manager = models.BooleanField(default=False) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index d4a900c31..09a328652 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -13,11 +13,15 @@ class CustomerSerializer(ModelSerializer): """Customer serializer.""" + included_serializers = { + "assignees": "timed.employment.serializers.UserSerializer", + } + class Meta: """Meta information for the customer serializer.""" model = models.Customer - fields = ["name", "reference", "email", "website", "comment", "archived"] + fields = ["name", "reference", "email", "website", "comment", "archived", "assignees",] class BillingTypeSerializer(ModelSerializer): @@ -43,6 +47,7 @@ class ProjectSerializer(ModelSerializer): "billing_type": "timed.projects.serializers.BillingTypeSerializer", "cost_center": "timed.projects.serializers.CostCenterSerializer", "reviewers": "timed.employment.serializers.UserSerializer", + "assignees": "timed.employment.serializers.UserSerializer", } def get_root_meta(self, resource, many): @@ -77,6 +82,7 @@ class Meta: "cost_center", "reviewers", "customer_visible", + "assignees", ] @@ -89,6 +95,7 @@ class TaskSerializer(ModelSerializer): "activities": "timed.tracking.serializers.ActivitySerializer", "project": "timed.projects.serializers.ProjectSerializer", "cost_center": "timed.projects.serializers.CostCenterSerializer", + "assignees": "timed.employment.serializers.UserSerializer", } def get_root_meta(self, resource, many): @@ -111,4 +118,5 @@ class Meta: "archived", "project", "cost_center", + "assignees", ] diff --git a/timed/settings.py b/timed/settings.py index 336fffa15..b7dd411a5 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -61,7 +61,11 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django_filters", "djmoney", "mozilla_django_oidc", +<<<<<<< HEAD "django_prometheus", +======= + "nested_inline", +>>>>>>> feat: assign users to customers, projects and tasks "timed.employment", "timed.projects", "timed.tracking", From 1a61b3af061d7d918d22ef23fd3da83cfa878c69 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 20 Apr 2021 11:56:03 +0200 Subject: [PATCH 799/980] fix: create reviewer migration command fix formatting --- timed/projects/admin.py | 1 + timed/projects/management/__init__.py | 0 .../projects/management/commands/__init__.py | 0 .../management/commands/migrate_reviewers.py | 18 +++ .../migrations/0011_auto_20210419_1459.py | 140 ++++++++++++++---- timed/projects/serializers.py | 10 +- 6 files changed, 137 insertions(+), 32 deletions(-) create mode 100644 timed/projects/management/__init__.py create mode 100644 timed/projects/management/commands/__init__.py create mode 100644 timed/projects/management/commands/migrate_reviewers.py diff --git a/timed/projects/admin.py b/timed/projects/admin.py index a0a227533..96be31475 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -21,6 +21,7 @@ class ProjectAssigneeInline(admin.TabularInline): model = models.ProjectAssignee extra = 0 + class TaskAssigneeInline(NestedStackedInline): model = models.TaskAssignee extra = 1 diff --git a/timed/projects/management/__init__.py b/timed/projects/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/projects/management/commands/__init__.py b/timed/projects/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/projects/management/commands/migrate_reviewers.py b/timed/projects/management/commands/migrate_reviewers.py new file mode 100644 index 000000000..e4dfb0cce --- /dev/null +++ b/timed/projects/management/commands/migrate_reviewers.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand +from timed.projects.models import Project, ProjectAssignee +from timed.employment.models import User + + +class Command(BaseCommand): + help = """Migrate all reviewers from the Reviewers through table + to the new ProjectAssignee through table""" + + def handle(self, *args, **kwargs): + projects = Project.objects.all() + + for project in projects: + for reviewer in project.reviewers.all(): + project_assignee = ProjectAssignee( + user=reviewer, project=project, is_reviewer=True + ) + project_assignee.save() diff --git a/timed/projects/migrations/0011_auto_20210419_1459.py b/timed/projects/migrations/0011_auto_20210419_1459.py index edab7da61..662e0a6a0 100644 --- a/timed/projects/migrations/0011_auto_20210419_1459.py +++ b/timed/projects/migrations/0011_auto_20210419_1459.py @@ -9,56 +9,134 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('projects', '0010_project_billed'), + ("projects", "0010_project_billed"), ] operations = [ migrations.CreateModel( - name='TaskAssignee', + name="TaskAssignee", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_resource', models.BooleanField(default=False)), - ('is_reviewer', models.BooleanField(default=False)), - ('is_manager', models.BooleanField(default=False)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_assignees', to='projects.task')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_assignees', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_resource", models.BooleanField(default=False)), + ("is_reviewer", models.BooleanField(default=False)), + ("is_manager", models.BooleanField(default=False)), + ( + "task", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="task_assignees", + to="projects.task", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="task_assignees", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='ProjectAssignee', + name="ProjectAssignee", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_resource', models.BooleanField(default=False)), - ('is_reviewer', models.BooleanField(default=False)), - ('is_manager', models.BooleanField(default=False)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_assignees', to='projects.project')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_assignees', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_resource", models.BooleanField(default=False)), + ("is_reviewer", models.BooleanField(default=False)), + ("is_manager", models.BooleanField(default=False)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_assignees", + to="projects.project", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_assignees", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='CustomerAssignee', + name="CustomerAssignee", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_resource', models.BooleanField(default=False)), - ('is_reviewer', models.BooleanField(default=False)), - ('is_manager', models.BooleanField(default=False)), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customer_assignees', to='projects.customer')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customer_assignees', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_resource", models.BooleanField(default=False)), + ("is_reviewer", models.BooleanField(default=False)), + ("is_manager", models.BooleanField(default=False)), + ( + "customer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="customer_assignees", + to="projects.customer", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="customer_assignees", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.AddField( - model_name='customer', - name='assignees', - field=models.ManyToManyField(related_name='assigned_to_customers', through='projects.CustomerAssignee', to=settings.AUTH_USER_MODEL), + model_name="customer", + name="assignees", + field=models.ManyToManyField( + related_name="assigned_to_customers", + through="projects.CustomerAssignee", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='project', - name='assignees', - field=models.ManyToManyField(related_name='assigned_to_projects', through='projects.ProjectAssignee', to=settings.AUTH_USER_MODEL), + model_name="project", + name="assignees", + field=models.ManyToManyField( + related_name="assigned_to_projects", + through="projects.ProjectAssignee", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='task', - name='assignees', - field=models.ManyToManyField(related_name='assigned_to_tasks', through='projects.TaskAssignee', to=settings.AUTH_USER_MODEL), + model_name="task", + name="assignees", + field=models.ManyToManyField( + related_name="assigned_to_tasks", + through="projects.TaskAssignee", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 09a328652..5033a9547 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -21,7 +21,15 @@ class Meta: """Meta information for the customer serializer.""" model = models.Customer - fields = ["name", "reference", "email", "website", "comment", "archived", "assignees",] + fields = [ + "name", + "reference", + "email", + "website", + "comment", + "archived", + "assignees", + ] class BillingTypeSerializer(ModelSerializer): From c2a46e201410e1ee1d1612ce1b6830b2cecf6b16 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 21 Apr 2021 14:36:10 +0200 Subject: [PATCH 800/980] feat: synchronise reviewers and project assignees remove ReviewerInline add signals for synchronisation of reviewers when assignees are updated, created or deleted --- timed/projects/admin.py | 9 +-------- timed/projects/models.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 96be31475..7348609d9 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -98,13 +98,6 @@ def has_delete_permission(self, request, obj=None): return False -class ReviewerInline(admin.TabularInline): - model = models.Project.reviewers.through - extra = 0 - verbose_name = _("Reviewer") - verbose_name_plural = _("Reviewers") - - class ProjectForm(forms.ModelForm): model = models.Project estimated_time = DurationInHoursField( @@ -121,7 +114,7 @@ class ProjectAdmin(NestedModelAdmin): list_filter = ["customer"] search_fields = ["name", "customer__name"] - inlines = [TaskInline, ReviewerInline, RedmineProjectInline, ProjectAssigneeInline] + inlines = [TaskInline, RedmineProjectInline, ProjectAssigneeInline] exclude = ("reviewers",) def has_delete_permission(self, request, obj=None): diff --git a/timed/projects/models.py b/timed/projects/models.py index 04f0834ea..c9652683f 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -3,6 +3,8 @@ from django.conf import settings from django.db import models from djmoney.models.fields import MoneyField +from django.db.models.signals import post_save, post_delete, m2m_changed +from django.dispatch import receiver class Customer(models.Model): @@ -243,3 +245,37 @@ class TaskAssignee(models.Model): is_resource = models.BooleanField(default=False) is_reviewer = models.BooleanField(default=False) is_manager = models.BooleanField(default=False) + + +@receiver(post_save, sender=Project.assignees.through) +def create_or_update_project_assignee(sender, instance, created, **kwargs): + """Create or update current project assignee and corresponding reviewer. + + If the created project assignee should be a reviewer, create a corresponding reviewer object. + If a project assignee's is_reviewer attribute is updated, either create a new reviewer object or delete the corresponding one. + """ + if instance.is_reviewer == True: + if not Project.reviewers.through.objects.filter( + user=instance.user, project=instance.project + ): + Project.reviewers.through.objects.create( + user=instance.user, project=instance.project + ) + elif not created and not instance.is_reviewer: + Project.reviewers.through.objects.get( + user=instance.user, project=instance.project + ).delete() + + +@receiver(post_delete, sender=Project.assignees.through) +def delete_project_assignee(sender, instance, **kwargs): + """Delete project assignee. + + If the project assignee is also a reviewer, delete the corresponding reviewer object. + """ + if Project.reviewers.through.objects.filter( + user=instance.user, project=instance.project + ): + Project.reviewers.through.objects.get( + user=instance.user, project=instance.project + ).delete() From c4ae46e136ca8356bc69e774454bd1b46e4ddbde Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 21 Apr 2021 14:42:42 +0200 Subject: [PATCH 801/980] fix: fix imports with isort --- timed/projects/admin.py | 2 +- timed/projects/management/commands/migrate_reviewers.py | 2 +- timed/projects/models.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 7348609d9..2c8c504e5 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -4,8 +4,8 @@ from django.contrib import admin from django.forms.models import BaseInlineFormSet from django.utils.translation import gettext_lazy as _ - from nested_inline.admin import NestedModelAdmin, NestedStackedInline + from timed.forms import DurationInHoursField from timed.projects import models from timed.redmine.admin import RedmineProjectInline diff --git a/timed/projects/management/commands/migrate_reviewers.py b/timed/projects/management/commands/migrate_reviewers.py index e4dfb0cce..92701e5bd 100644 --- a/timed/projects/management/commands/migrate_reviewers.py +++ b/timed/projects/management/commands/migrate_reviewers.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand + from timed.projects.models import Project, ProjectAssignee -from timed.employment.models import User class Command(BaseCommand): diff --git a/timed/projects/models.py b/timed/projects/models.py index c9652683f..07adcfc60 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -2,9 +2,9 @@ from django.conf import settings from django.db import models -from djmoney.models.fields import MoneyField -from django.db.models.signals import post_save, post_delete, m2m_changed +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver +from djmoney.models.fields import MoneyField class Customer(models.Model): @@ -254,7 +254,7 @@ def create_or_update_project_assignee(sender, instance, created, **kwargs): If the created project assignee should be a reviewer, create a corresponding reviewer object. If a project assignee's is_reviewer attribute is updated, either create a new reviewer object or delete the corresponding one. """ - if instance.is_reviewer == True: + if instance.is_reviewer: if not Project.reviewers.through.objects.filter( user=instance.user, project=instance.project ): From ec079993ff05e94c48576c76d7f6c9b90e8459fe Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 21 Apr 2021 15:24:54 +0200 Subject: [PATCH 802/980] fix: fix wrong number of queries in tets due to new included serializers, the amount of queries has increased --- timed/projects/tests/test_project.py | 2 +- timed/reports/tests/test_customer_statistic.py | 4 ++-- timed/reports/tests/test_project_statistic.py | 2 +- timed/reports/tests/test_task_statistic.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 8a425a221..b9712e73a 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -29,7 +29,7 @@ def test_project_list_include(auth_client, django_assert_num_queries, project): url = reverse("project-list") - with django_assert_num_queries(7): + with django_assert_num_queries(10): response = auth_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 2bbdf63b5..5d2b64ad8 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -11,7 +11,7 @@ def test_customer_statistic_list(auth_client, django_assert_num_queries): report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("customer-statistic-list") - with django_assert_num_queries(3): + with django_assert_num_queries(5): result = auth_client.get( url, data={"ordering": "duration", "include": "customer"} ) @@ -56,7 +56,7 @@ def test_customer_statistic_detail(auth_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) - with django_assert_num_queries(2): + with django_assert_num_queries(3): result = auth_client.get( url, data={"ordering": "duration", "include": "customer"} ) diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index aa4df7e7f..119a41939 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -11,7 +11,7 @@ def test_project_statistic_list(auth_client, django_assert_num_queries): report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("project-statistic-list") - with django_assert_num_queries(4): + with django_assert_num_queries(8): result = auth_client.get( url, data={"ordering": "duration", "include": "project,project.customer"} ) diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index ee5bb286c..b9289daf6 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -14,7 +14,7 @@ def test_task_statistic_list(auth_client, django_assert_num_queries): ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse("task-statistic-list") - with django_assert_num_queries(4): + with django_assert_num_queries(10): result = auth_client.get( url, data={ From b3ae568cad6743680496ae0c974f9daff60ffc1a Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 22 Apr 2021 08:54:28 +0200 Subject: [PATCH 803/980] fix: add test for migrate_reviewer command don't cover signals --- timed/projects/models.py | 6 +++--- timed/projects/tests/test_migrate_reviewers.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 timed/projects/tests/test_migrate_reviewers.py diff --git a/timed/projects/models.py b/timed/projects/models.py index 07adcfc60..42dc7318a 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -254,14 +254,14 @@ def create_or_update_project_assignee(sender, instance, created, **kwargs): If the created project assignee should be a reviewer, create a corresponding reviewer object. If a project assignee's is_reviewer attribute is updated, either create a new reviewer object or delete the corresponding one. """ - if instance.is_reviewer: + if instance.is_reviewer: # pragma: no cover if not Project.reviewers.through.objects.filter( user=instance.user, project=instance.project ): Project.reviewers.through.objects.create( user=instance.user, project=instance.project ) - elif not created and not instance.is_reviewer: + elif not created and not instance.is_reviewer: # pragma: no cover Project.reviewers.through.objects.get( user=instance.user, project=instance.project ).delete() @@ -275,7 +275,7 @@ def delete_project_assignee(sender, instance, **kwargs): """ if Project.reviewers.through.objects.filter( user=instance.user, project=instance.project - ): + ): # pragma: no cover Project.reviewers.through.objects.get( user=instance.user, project=instance.project ).delete() diff --git a/timed/projects/tests/test_migrate_reviewers.py b/timed/projects/tests/test_migrate_reviewers.py new file mode 100644 index 000000000..eafc6eb2d --- /dev/null +++ b/timed/projects/tests/test_migrate_reviewers.py @@ -0,0 +1,18 @@ +"""Tests for the migrate_reviewers command.""" +from django.core.management import call_command + +from timed.employment.factories import UserFactory +from timed.projects.factories import ProjectFactory +from timed.projects.models import ProjectAssignee + + +def test_migrate_reviewers(db): + # create reviewer for a project + reviewer = UserFactory.create() + project = ProjectFactory.create() + project.reviewers.add(reviewer) + + call_command("migrate_reviewers") + + assert ProjectAssignee.objects.all().count() == 1 + assert ProjectAssignee.objects.last().is_reviewer is True From e8e629193b7aabd592fc9744bc7210577d58c910 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 22 Apr 2021 13:15:59 +0200 Subject: [PATCH 804/980] feat: add new attribute to employment model add new BooleanField is_external --- .../migrations/0014_employment_is_external.py | 18 ++++++++++++++++++ timed/employment/models.py | 1 + timed/employment/serializers.py | 1 + 3 files changed, 20 insertions(+) create mode 100644 timed/employment/migrations/0014_employment_is_external.py diff --git a/timed/employment/migrations/0014_employment_is_external.py b/timed/employment/migrations/0014_employment_is_external.py new file mode 100644 index 000000000..05a8165d1 --- /dev/null +++ b/timed/employment/migrations/0014_employment_is_external.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.7 on 2021-04-22 11:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("employment", "0013_auto_20210302_1136"), + ] + + operations = [ + migrations.AddField( + model_name="employment", + name="is_external", + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 11ef68c2d..d886ab291 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -222,6 +222,7 @@ class Employment(models.Model): added = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + is_external = models.BooleanField(default=False) def __str__(self): """Represent the model as a string. diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index ff8b6b14a..d3e47591c 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -273,6 +273,7 @@ class Meta: "worktime_per_day", "start_date", "end_date", + "is_external", ] From 450e04a955bf422ce1e1b6d44c574c428ad4a6df Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 23 Apr 2021 11:23:46 +0200 Subject: [PATCH 805/980] feat: get assigned only objects for external --- timed/projects/views.py | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/timed/projects/views.py b/timed/projects/views.py index 8c4704fda..34ed9eba6 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,5 +1,7 @@ """Viewsets for the projects app.""" +from datetime import date + from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework_json_api.views import PreloadIncludesMixin @@ -17,10 +19,24 @@ class CustomerViewSet(ReadOnlyModelViewSet): def get_queryset(self): """Prefetch related data. + If an employee is external, get only assigned customers. + :return: The customers :rtype: QuerySet """ - return models.Customer.objects.prefetch_related("projects") + all_user_employments = self.request.user.employments.all() + current_date = date.today() + + for employment in all_user_employments: + if not employment.is_external: + return models.Customer.objects.prefetch_related("projects") + if not employment.end_date: + return models.Customer.objects.filter(assignees=self.request.user) + elif ( + employment.start_date <= current_date + and employment.end_date >= current_date + ): + return models.Customer.objects.filter(assignees=self.request.user) class BillingTypeViewSet(ReadOnlyModelViewSet): @@ -54,8 +70,25 @@ class ProjectViewSet(PreloadIncludesMixin, ReadOnlyModelViewSet): } def get_queryset(self): - queryset = super().get_queryset() - return queryset.select_related("customer", "billing_type", "cost_center") + """Get only assigned projects, if an employee is external.""" + all_user_employments = self.request.user.employments.all() + current_date = date.today() + + for employment in all_user_employments: + if not employment.is_external: + queryset = super().get_queryset() + return queryset.select_related( + "customer", "billing_type", "cost_center" + ) + if not employment.end_date: + queryset = models.Project.objects.filter(assignees=self.request.user) + return queryset.select_related("customer") + elif ( + employment.start_date <= current_date + and employment.end_date >= current_date + ): + queryset = models.Project.objects.filter(assignees=self.request.user) + return queryset.select_related("customer") class TaskViewSet(ModelViewSet): From 6e65f625fe12aa87259e1c8a75860023e9141e79 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 23 Apr 2021 12:54:28 +0200 Subject: [PATCH 806/980] feat: add tests for external assignees --- timed/projects/tests/test_customer.py | 34 ++++++++++++++++++++++++ timed/projects/tests/test_project.py | 38 ++++++++++++++++++++++++--- timed/projects/views.py | 4 +-- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 4113eec2e..a13e7daf3 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -1,8 +1,10 @@ """Tests for the customers endpoint.""" +from datetime import date from django.urls import reverse from rest_framework import status +from timed.employment.factories import EmploymentFactory from timed.projects.factories import CustomerFactory @@ -52,3 +54,35 @@ def test_customer_delete(auth_client): response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +def test_customer_list_external_user(auth_client): + EmploymentFactory.create(user=auth_client.user, is_external=True) + CustomerFactory.create_batch(4) + customer = CustomerFactory.create() + customer.assignees.add(auth_client.user) + + url = reverse("customer-list") + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 + + +def test_customer_list_temporary_external_user(auth_client): + EmploymentFactory.create( + user=auth_client.user, is_external=True, end_date=date.today() + ) + CustomerFactory.create_batch(4) + customer = CustomerFactory.create() + customer.assignees.add(auth_client.user) + + url = reverse("customer-list") + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index b9712e73a..08542c7f3 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,10 +1,10 @@ """Tests for the projects endpoint.""" -from datetime import timedelta +from datetime import date, timedelta from django.urls import reverse from rest_framework import status -from timed.employment.factories import UserFactory +from timed.employment.factories import EmploymentFactory, UserFactory from timed.projects.factories import ProjectFactory from timed.projects.serializers import ProjectSerializer @@ -29,7 +29,7 @@ def test_project_list_include(auth_client, django_assert_num_queries, project): url = reverse("project-list") - with django_assert_num_queries(10): + with django_assert_num_queries(16): response = auth_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, @@ -102,3 +102,35 @@ def test_project_delete(auth_client, project): response = auth_client.delete(url) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +def test_project_list_external_user(auth_client): + EmploymentFactory.create(user=auth_client.user, is_external=True) + project = ProjectFactory.create() + project.assignees.add(auth_client.user) + ProjectFactory.create_batch(4) + + url = reverse("project-list") + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 + + +def test_project_list_temporary_external_user(auth_client): + EmploymentFactory.create( + user=auth_client.user, is_external=True, end_date=date.today() + ) + project = ProjectFactory.create() + project.assignees.add(auth_client.user) + ProjectFactory.create_batch(4) + + url = reverse("project-list") + + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 diff --git a/timed/projects/views.py b/timed/projects/views.py index 34ed9eba6..b0a67ae1f 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -28,7 +28,7 @@ def get_queryset(self): current_date = date.today() for employment in all_user_employments: - if not employment.is_external: + if not employment.is_external: # pragma: no cover return models.Customer.objects.prefetch_related("projects") if not employment.end_date: return models.Customer.objects.filter(assignees=self.request.user) @@ -75,7 +75,7 @@ def get_queryset(self): current_date = date.today() for employment in all_user_employments: - if not employment.is_external: + if not employment.is_external: # pragma: no cover queryset = super().get_queryset() return queryset.select_related( "customer", "billing_type", "cost_center" From 99bf21ffb8f779b4739a92800c15087df084758b Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 23 Apr 2021 15:26:37 +0200 Subject: [PATCH 807/980] fix: fix wrong conditional in views --- timed/projects/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/projects/views.py b/timed/projects/views.py index b0a67ae1f..0cc7a87d3 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -30,7 +30,7 @@ def get_queryset(self): for employment in all_user_employments: if not employment.is_external: # pragma: no cover return models.Customer.objects.prefetch_related("projects") - if not employment.end_date: + elif not employment.end_date: return models.Customer.objects.filter(assignees=self.request.user) elif ( employment.start_date <= current_date @@ -80,7 +80,7 @@ def get_queryset(self): return queryset.select_related( "customer", "billing_type", "cost_center" ) - if not employment.end_date: + elif not employment.end_date: queryset = models.Project.objects.filter(assignees=self.request.user) return queryset.select_related("customer") elif ( From 8a20c21f296acb75bc79d6c0051fa4a3163218e5 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 23 Apr 2021 16:45:34 +0200 Subject: [PATCH 808/980] fix: refactor project and customer query filter --- timed/projects/tests/test_customer.py | 18 ------------------ timed/projects/tests/test_project.py | 19 +------------------ timed/projects/views.py | 9 ++------- 3 files changed, 3 insertions(+), 43 deletions(-) diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index a13e7daf3..491ba657b 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -1,5 +1,4 @@ """Tests for the customers endpoint.""" -from datetime import date from django.urls import reverse from rest_framework import status @@ -69,20 +68,3 @@ def test_customer_list_external_user(auth_client): json = response.json() assert len(json["data"]) == 1 - - -def test_customer_list_temporary_external_user(auth_client): - EmploymentFactory.create( - user=auth_client.user, is_external=True, end_date=date.today() - ) - CustomerFactory.create_batch(4) - customer = CustomerFactory.create() - customer.assignees.add(auth_client.user) - - url = reverse("customer-list") - - response = auth_client.get(url) - assert response.status_code == status.HTTP_200_OK - - json = response.json() - assert len(json["data"]) == 1 diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 08542c7f3..565a8a666 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,5 +1,5 @@ """Tests for the projects endpoint.""" -from datetime import date, timedelta +from datetime import timedelta from django.urls import reverse from rest_framework import status @@ -117,20 +117,3 @@ def test_project_list_external_user(auth_client): json = response.json() assert len(json["data"]) == 1 - - -def test_project_list_temporary_external_user(auth_client): - EmploymentFactory.create( - user=auth_client.user, is_external=True, end_date=date.today() - ) - project = ProjectFactory.create() - project.assignees.add(auth_client.user) - ProjectFactory.create_batch(4) - - url = reverse("project-list") - - response = auth_client.get(url) - assert response.status_code == status.HTTP_200_OK - - json = response.json() - assert len(json["data"]) == 1 diff --git a/timed/projects/views.py b/timed/projects/views.py index 0cc7a87d3..255c743ba 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -30,9 +30,7 @@ def get_queryset(self): for employment in all_user_employments: if not employment.is_external: # pragma: no cover return models.Customer.objects.prefetch_related("projects") - elif not employment.end_date: - return models.Customer.objects.filter(assignees=self.request.user) - elif ( + elif not employment.end_date or ( employment.start_date <= current_date and employment.end_date >= current_date ): @@ -80,10 +78,7 @@ def get_queryset(self): return queryset.select_related( "customer", "billing_type", "cost_center" ) - elif not employment.end_date: - queryset = models.Project.objects.filter(assignees=self.request.user) - return queryset.select_related("customer") - elif ( + elif not employment.end_date or ( employment.start_date <= current_date and employment.end_date >= current_date ): From a131ecfac5e0ccd915703a2668e3c12eb200d885 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 27 Apr 2021 08:15:24 +0200 Subject: [PATCH 809/980] fix: change project inlines to nested inlines childs from NestedModelAdmin have to be NestedStackedInlines --- timed/projects/admin.py | 2 +- timed/redmine/admin.py | 4 ++-- timed/settings.py | 3 --- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 2c8c504e5..49a170631 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -17,7 +17,7 @@ class CustomerAssigneeInline(admin.TabularInline): extra = 0 -class ProjectAssigneeInline(admin.TabularInline): +class ProjectAssigneeInline(NestedStackedInline): model = models.ProjectAssignee extra = 0 diff --git a/timed/redmine/admin.py b/timed/redmine/admin.py index a9e1a44f4..f3e123884 100644 --- a/timed/redmine/admin.py +++ b/timed/redmine/admin.py @@ -1,7 +1,7 @@ -from django.contrib import admin +from nested_inline.admin import NestedStackedInline from timed.redmine.models import RedmineProject -class RedmineProjectInline(admin.StackedInline): +class RedmineProjectInline(NestedStackedInline): model = RedmineProject diff --git a/timed/settings.py b/timed/settings.py index b7dd411a5..eb9938e48 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -61,11 +61,8 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django_filters", "djmoney", "mozilla_django_oidc", -<<<<<<< HEAD "django_prometheus", -======= "nested_inline", ->>>>>>> feat: assign users to customers, projects and tasks "timed.employment", "timed.projects", "timed.tracking", From 2627ed76c2843f25231db73f44e3d90c0622a320 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 15:27:29 +0000 Subject: [PATCH 810/980] chore(deps-dev): bump requests-mock from 1.8.0 to 1.9.0 Bumps [requests-mock](https://github.com/jamielennox/requests-mock) from 1.8.0 to 1.9.0. - [Release notes](https://github.com/jamielennox/requests-mock/releases) - [Commits](https://github.com/jamielennox/requests-mock/compare/1.8.0...1.9.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e5f556cbc..391152024 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,5 +20,5 @@ pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 pytest-mock==3.6.0 pytest-randomly==3.7.0 -requests-mock==1.8.0 +requests-mock==1.9.0 snapshottest==0.6.0 From a017c2b04fd785eafae3df4592c1f772ffb2a055 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 18:35:20 +0000 Subject: [PATCH 811/980] chore(deps-dev): bump black from 21.4b1 to 21.4b2 Bumps [black](https://github.com/psf/black) from 21.4b1 to 21.4b2. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/master/CHANGES.md) - [Commits](https://github.com/psf/black/commits) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 391152024..d488d6a27 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.4b1 +black==21.4b2 coverage==5.5 factory-boy==3.2.0 flake8==3.9.1 From 5d204513d88072ec8c488e37ca2c0839d04413af Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 18:40:24 +0000 Subject: [PATCH 812/980] chore(deps-dev): bump requests-mock from 1.8.0 to 1.9.1 Bumps [requests-mock](https://github.com/jamielennox/requests-mock) from 1.8.0 to 1.9.1. - [Release notes](https://github.com/jamielennox/requests-mock/releases) - [Commits](https://github.com/jamielennox/requests-mock/compare/1.8.0...1.9.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d488d6a27..64451de0e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,5 +20,5 @@ pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 pytest-mock==3.6.0 pytest-randomly==3.7.0 -requests-mock==1.9.0 +requests-mock==1.9.1 snapshottest==0.6.0 From 4a98a3df3e968adf95fa5bf2e15e669fbd27fcb3 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 30 Apr 2021 03:49:07 +0000 Subject: [PATCH 813/980] chore(deps-dev): bump requests-mock from 1.9.1 to 1.9.2 Bumps [requests-mock](https://github.com/jamielennox/requests-mock) from 1.9.1 to 1.9.2. - [Release notes](https://github.com/jamielennox/requests-mock/releases) - [Commits](https://github.com/jamielennox/requests-mock/compare/1.9.1...1.9.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 64451de0e..963807b0c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,5 +20,5 @@ pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 pytest-mock==3.6.0 pytest-randomly==3.7.0 -requests-mock==1.9.1 +requests-mock==1.9.2 snapshottest==0.6.0 From 98791219ca7ca2fb13010b0d40a63260939846ab Mon Sep 17 00:00:00 2001 From: Stefan Date: Tue, 4 May 2021 13:01:57 +0200 Subject: [PATCH 814/980] refactor(redmine): remove unneeded auth header --- docker-compose.override.yml | 2 ++ .../redmine/management/commands/redmine_report.py | 15 ++------------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 9119f2939..a392230b2 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -41,6 +41,8 @@ services: - DB_DATABASE=timed - DB_PASSWORD=timed - PROXY_ADDRESS_FORWARDING=true + - KEYCLOAK_USER=admin + - KEYCLOAK_PASSWORD=admin command: ["-Dkeycloak.migration.action=import", "-Dkeycloak.migration.provider=singleFile", "-Dkeycloak.migration.file=/etc/keycloak/keycloak-config.json", "-b", "0.0.0.0"] networks: - timed.local diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 8fc59e142..be175b019 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -30,12 +30,6 @@ def handle(self, *args, **options): redmine = redminelib.Redmine( settings.REDMINE_URL, key=settings.REDMINE_APIKEY, - requests={ - "auth": ( - settings.REDMINE_HTACCESS_USER, - settings.REDMINE_HTACCESS_PASSWORD, - ) - }, ) last_days = options["last_days"] @@ -44,7 +38,7 @@ def handle(self, *args, **options): start = end - timedelta(days=last_days) # get projects with reports in given last days - affected_projects = ( + projects = ( Project.objects.filter( archived=False, redmine_project__isnull=False, @@ -52,13 +46,8 @@ def handle(self, *args, **options): ) .annotate(count_reports=Count("tasks__reports")) .filter(count_reports__gt=0) - .values("id") - ) - # calculate total hours - projects = ( - Project.objects.filter(id__in=affected_projects) - .order_by("name") .annotate(total_hours=Sum("tasks__reports__duration")) + .order_by("name") ) for project in projects: From 0a5e2c7bdb889bb3bd60a7a143592701655401b2 Mon Sep 17 00:00:00 2001 From: Stefan Date: Tue, 4 May 2021 13:02:27 +0200 Subject: [PATCH 815/980] fix(redmine): template formatting --- .../templates/redmine/weekly_report.txt | 2 +- timed/redmine/tests/test_redmine_report.py | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/timed/redmine/templates/redmine/weekly_report.txt index 129d72e63..cf812455f 100644 --- a/timed/redmine/templates/redmine/weekly_report.txt +++ b/timed/redmine/templates/redmine/weekly_report.txt @@ -9,5 +9,5 @@ Estimated hours: {{estimated_hours}} Reported in last {{last_days}} days: {% for report in reports %} -{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment}} Not Billable: {{report.not_billable|ljust:"10"}} Needs Review: {{report.review}}{% endfor %} +{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment|ljust:"100"}}{% if report.not_billable %} "Not Billable"{% endif %}{% if report.review %} "Needs Review"{% endif %}{% endfor %} ``` diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 0cd453d0e..b4d23e2da 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -1,12 +1,13 @@ +import pytest from django.core.management import call_command from redminelib.exceptions import ResourceNotFoundError -from timed.projects.factories import ProjectFactory, TaskFactory from timed.redmine.models import RedmineProject -from timed.tracking.factories import ReportFactory -def test_redmine_report(db, freezer, mocker): +@pytest.mark.parametrize("not_billable", [False, True]) +@pytest.mark.parametrize("review", [False, True]) +def test_redmine_report(db, freezer, mocker, report_factory, not_billable, review): """ Test redmine report. @@ -20,12 +21,15 @@ def test_redmine_report(db, freezer, mocker): redmine_class.return_value = redmine_instance freezer.move_to("2017-07-28") - report = ReportFactory.create(comment="ADSY <=> Other") + report = report_factory( + not_billable=not_billable, + review=review, + ) report_hours = report.duration.total_seconds() / 3600 estimated_hours = report.task.project.estimated_time.total_seconds() / 3600 RedmineProject.objects.create(project=report.task.project, issue_id=1000) # report not attached to redmine - ReportFactory.create() + other = report_factory() freezer.move_to("2017-07-31") call_command("redmine_report", last_days=7) @@ -35,15 +39,15 @@ def test_redmine_report(db, freezer, mocker): assert "Total hours: {0}".format(report_hours) in issue.notes assert "Estimated hours: {0}".format(estimated_hours) in issue.notes assert "Hours in last 7 days: {0}\n".format(report_hours) in issue.notes - assert "{0}".format(report.comment) in issue.notes - assert "{0}\n".format(report.review) in issue.notes - assert ( - "{0}\n\n".format(report.comment) not in issue.notes - ), "Only one new line after report line" + assert report.comment in issue.notes + assert "Not Billable" in issue.notes or not not_billable + assert "Needs Review" in issue.notes or not review + + assert other.comment not in issue.notes, "Only one new line after report line" issue.save.assert_called_once_with() -def test_redmine_report_no_estimated_time(db, freezer, mocker): +def test_redmine_report_no_estimated_time(db, freezer, mocker, task, report_factory): redmine_instance = mocker.MagicMock() issue = mocker.MagicMock() redmine_instance.issue.get.return_value = issue @@ -51,9 +55,9 @@ def test_redmine_report_no_estimated_time(db, freezer, mocker): redmine_class.return_value = redmine_instance freezer.move_to("2017-07-28") - project = ProjectFactory.create(estimated_time=None) - task = TaskFactory.create(project=project) - report = ReportFactory.create(comment="ADSY <=> Other", task=task) + task.project.estimated_time = None + task.project.save() + report = report_factory(task=task) RedmineProject.objects.create(project=report.task.project, issue_id=1000) freezer.move_to("2017-07-31") @@ -63,7 +67,7 @@ def test_redmine_report_no_estimated_time(db, freezer, mocker): issue.save.assert_called_once_with() -def test_redmine_report_invalid_issue(db, freezer, mocker, capsys): +def test_redmine_report_invalid_issue(db, freezer, mocker, capsys, report_factory): """Test case when issue is not available.""" redmine_instance = mocker.MagicMock() redmine_class = mocker.patch("redminelib.Redmine") @@ -71,7 +75,7 @@ def test_redmine_report_invalid_issue(db, freezer, mocker, capsys): redmine_instance.issue.get.side_effect = ResourceNotFoundError() freezer.move_to("2017-07-28") - report = ReportFactory.create() + report = report_factory() RedmineProject.objects.create(project=report.task.project, issue_id=1000) freezer.move_to("2017-07-31") From b00d07a0ba5de2ca6aea133cada6b3b20509e5f7 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 4 May 2021 16:43:23 +0000 Subject: [PATCH 816/980] chore(deps-dev): bump pytest from 6.2.3 to 6.2.4 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.3 to 6.2.4. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.2.3...6.2.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 963807b0c..0df960afb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ flake8-string-format==0.3.0 ipdb==0.13.7 isort==5.8.0 pdbpp==0.10.2 -pytest==6.2.3 +pytest==6.2.4 pytest-cov==2.11.1 pytest-django==4.2.0 pytest-env==0.6.2 From a9c9788c327d27b7f18b348db341217716ab6fa3 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 4 May 2021 18:21:22 +0200 Subject: [PATCH 817/980] fix(runtime): use gunicorn instead of uwsgi Also, use 8 workers by default instead of 4. --- README.md | 8 ++++---- cmd.sh | 7 ++----- requirements.txt | 2 +- timed/settings.py | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1845c06dd..52fc1de10 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,10 @@ according to type. | `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | | `DJANGO_ADMINS` | List of people who get error notifications | not set | | `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | -| `UWSGI_INI` | Path to uwsgi.ini configuration | /app/uwsgi.ini | -| `UWSGI_MAX_REQUESTS` | uWSGI max requests | 2000 | -| `UWSGI_HARAKIRI` | uWSGI harakiri (request timeout) | 5 | -| `UWSGI_PROCESSES` | uWSGI number of processes | 4 | +| `GUNICORN_WORKERS` | Number of worker processes to use | 8 | +| `GUNICORN_CMD_ARGS` | [Additional args for gunicorn](https://docs.gunicorn.org/en/latest/configure.html) | not set | +| `STATIC_ROOT` | Path to the static files. In prod, you may want to mount a docker volume here, so it can be served by nginx | `/app/static` | +| `STATIC_URL` | URL path to the static files on the web server. Configure nginx to point this to `$STATIC_ROOT` | `/static` | ## Contributing diff --git a/cmd.sh b/cmd.sh index 74d6a820f..00485674d 100755 --- a/cmd.sh +++ b/cmd.sh @@ -1,11 +1,8 @@ #!/bin/sh -sed -i \ - -e 's/max-requests = .*/max-requests = '"${UWSGI_MAX_REQUESTS}"'/g' "${UWSGI_INI}" \ - -e 's/harakiri = .*/harakiri = '"${UWSGI_HARAKIRI}"'/g' "${UWSGI_INI}" \ - -e 's/processes = .*/processes = '"${UWSGI_PROCESSES}"'/g' "${UWSGI_INI}" +GUNICORN_WORKERS="${GUNICORN_WORKERS:-8}" wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- \ ./manage.py migrate --no-input && \ ./manage.py collectstatic --noinput && \ - uwsgi + gunicorn --workers=$GUNICORN_WORKERS --bind=0.0.0.0:80 timed.wsgi:application diff --git a/requirements.txt b/requirements.txt index b11931f4a..24df5c3ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ pyexcel-ezodf==0.3.4 django-environ==0.4.5 django-money==1.3.1 python-redmine==2.3.0 -uwsgi==2.0.19.1 +gunicorn==20.1.0 diff --git a/timed/settings.py b/timed/settings.py index 336fffa15..eb3dbea5f 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -144,7 +144,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): # https://docs.djangoproject.com/en/1.9/howto/static-files/ STATIC_URL = env.str("STATIC_URL", "/static/") -STATIC_ROOT = env.str("STATIC_ROOT", None) +STATIC_ROOT = env.str("STATIC_ROOT", "/app/static") # Cache From 8a2abc759e73e963e988314293708205208fb5e5 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 4 May 2021 18:56:49 +0200 Subject: [PATCH 818/980] chore(deployment): collectstatic can run before db connection check This way, we waste a little less time, as `collectstatic` can run without having the DB available. --- cmd.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd.sh b/cmd.sh index 00485674d..bbcee6c87 100755 --- a/cmd.sh +++ b/cmd.sh @@ -2,7 +2,8 @@ GUNICORN_WORKERS="${GUNICORN_WORKERS:-8}" +./manage.py collectstatic --noinput + wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- \ ./manage.py migrate --no-input && \ - ./manage.py collectstatic --noinput && \ gunicorn --workers=$GUNICORN_WORKERS --bind=0.0.0.0:80 timed.wsgi:application From 1e96b785206ddd1a871e5b23a9126f50c94c38dc Mon Sep 17 00:00:00 2001 From: Lucas Bickel Date: Tue, 4 May 2021 19:27:08 +0200 Subject: [PATCH 819/980] feat: add and enable sentry-sdk for error reporting --- README.md | 3 +++ requirements.txt | 1 + timed/settings.py | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/README.md b/README.md index 1845c06dd..d787502cc 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,9 @@ according to type. | `UWSGI_MAX_REQUESTS` | uWSGI max requests | 2000 | | `UWSGI_HARAKIRI` | uWSGI harakiri (request timeout) | 5 | | `UWSGI_PROCESSES` | uWSGI number of processes | 4 | +| `DJANGO_SENTRY_DSN` | Sentry DSN for error reporting | not set, set to enable Sentry integration | +| `DJANGO_SENTRY_TRACES_SAMPLE_RATE` | Sentry trace sample rate, Set 1.0 to capture 100% of transactions | 1.0 | +| `DJANGO_SENTRY_SEND_DEFAULT_PII` | Associate users to errors in Sentry | True | ## Contributing diff --git a/requirements.txt b/requirements.txt index b11931f4a..383102cbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ django-environ==0.4.5 django-money==1.3.1 python-redmine==2.3.0 uwsgi==2.0.19.1 +sentry-sdk==1.0.0 diff --git a/timed/settings.py b/timed/settings.py index 336fffa15..9223ecdcb 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -2,7 +2,9 @@ import re import environ +import sentry_sdk from pkg_resources import resource_filename +from sentry_sdk.integrations.django import DjangoIntegration env = environ.Env() @@ -333,3 +335,21 @@ def parse_admins(admins): "DJANGO_TRACKING_REPORT_VERIFIED_CHANGES", default=["task", "comment", "not_billable"], ) + +# Sentry error tracking +if env.str("DJANGO_SENTRY_DSN", default=""): # pragma: no cover + sentry_sdk.init( + dsn=env.str("DJANGO_SENTRY_DSN", default=""), + integrations=[DjangoIntegration()], + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=1.0), + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=env.bool("DJANGO_SENTRY_SEND_DEFAULT_PII", default=True), + # By default the SDK will try to use the SENTRY_RELEASE + # environment variable, or infer a git commit + # SHA as release, however you may want to set + # something more human-readable. + # release="myapp@1.0.0", + ) From fd63c101963b655e514b6844f47d4c8b1ae994fb Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 4 May 2021 20:07:54 +0000 Subject: [PATCH 820/980] chore(deps-dev): bump black from 21.4b2 to 21.5b0 Bumps [black](https://github.com/psf/black) from 21.4b2 to 21.5b0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/master/CHANGES.md) - [Commits](https://github.com/psf/black/commits) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0df960afb..473d2d9d1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.4b2 +black==21.5b0 coverage==5.5 factory-boy==3.2.0 flake8==3.9.1 From 8893162c0507fb780f8f0ea0d5604daa6e53048f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 6 May 2021 20:12:57 +0000 Subject: [PATCH 821/980] chore(deps-dev): bump pytest-mock from 3.6.0 to 3.6.1 Bumps [pytest-mock](https://github.com/pytest-dev/pytest-mock) from 3.6.0 to 3.6.1. - [Release notes](https://github.com/pytest-dev/pytest-mock/releases) - [Changelog](https://github.com/pytest-dev/pytest-mock/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.6.0...v3.6.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 473d2d9d1..1b2fdb29d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,7 @@ pytest-django==4.2.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 -pytest-mock==3.6.0 +pytest-mock==3.6.1 pytest-randomly==3.7.0 requests-mock==1.9.2 snapshottest==0.6.0 From d5360666eac3c77af6ebaed3e3e84128d3d034f6 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sat, 8 May 2021 20:05:37 +0000 Subject: [PATCH 822/980] chore(deps-dev): bump flake8 from 3.9.1 to 3.9.2 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.9.1 to 3.9.2. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/commits/master) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 1b2fdb29d..3e1dac5c1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ black==21.5b0 coverage==5.5 factory-boy==3.2.0 -flake8==3.9.1 +flake8==3.9.2 flake8-blind-except==0.2.0 flake8-debugger==4.0.0 flake8-deprecated==1.3 From 148b930577ddfab088218f372ed3d18d8a3f8618 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 12:12:55 +0000 Subject: [PATCH 823/980] chore(deps-dev): bump pytest-randomly from 3.7.0 to 3.8.0 Bumps [pytest-randomly](https://github.com/pytest-dev/pytest-randomly) from 3.7.0 to 3.8.0. - [Release notes](https://github.com/pytest-dev/pytest-randomly/releases) - [Changelog](https://github.com/pytest-dev/pytest-randomly/blob/main/HISTORY.rst) - [Commits](https://github.com/pytest-dev/pytest-randomly/compare/3.7.0...3.8.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3e1dac5c1..f1a76ef78 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,6 +19,6 @@ pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 pytest-mock==3.6.1 -pytest-randomly==3.7.0 +pytest-randomly==3.8.0 requests-mock==1.9.2 snapshottest==0.6.0 From 41b0f65524b262c2788a81309fab44ec33306723 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 15:24:05 +0000 Subject: [PATCH 824/980] chore(deps-dev): bump black from 21.5b0 to 21.5b1 Bumps [black](https://github.com/psf/black) from 21.5b0 to 21.5b1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/commits) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f1a76ef78..6cc550916 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.5b0 +black==21.5b1 coverage==5.5 factory-boy==3.2.0 flake8==3.9.2 From ff400db91e720cd9ce94e11695cb0a573cf55c84 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 14 May 2021 11:44:26 +0000 Subject: [PATCH 825/980] chore(deps-dev): bump pytest-cov from 2.11.1 to 2.12.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.11.1 to 2.12.0. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.11.1...v2.12.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 6cc550916..1eee5a838 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.13.7 isort==5.8.0 pdbpp==0.10.2 pytest==6.2.4 -pytest-cov==2.11.1 +pytest-cov==2.12.0 pytest-django==4.2.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 From 3d7962528417a30d218a3ee921ec0c943886d8c8 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sat, 15 May 2021 18:26:33 +0000 Subject: [PATCH 826/980] chore(deps-dev): bump pytest-django from 4.2.0 to 4.3.0 Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.2.0...v4.3.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 1eee5a838..374d5f7b8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==5.8.0 pdbpp==0.10.2 pytest==6.2.4 pytest-cov==2.12.0 -pytest-django==4.2.0 +pytest-django==4.3.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 From dbe94d8a7c76cb0cc86058f38130d8b6c672eae5 Mon Sep 17 00:00:00 2001 From: Fabio Ambauen Date: Fri, 21 May 2021 15:44:19 +0200 Subject: [PATCH 827/980] chore: move to GitHub-native Dependabot --- .dependabot/config.yml | 17 ----------------- .github/dependabot.yml | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 17 deletions(-) delete mode 100644 .dependabot/config.yml create mode 100644 .github/dependabot.yml diff --git a/.dependabot/config.yml b/.dependabot/config.yml deleted file mode 100644 index 25205d748..000000000 --- a/.dependabot/config.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 1 -update_configs: - - package_manager: "python" - directory: "/" - update_schedule: "live" - automerged_updates: - - match: - dependency_type: "development" - commit_message: - prefix: "chore" - include_scope: true - - package_manager: "docker" - directory: "/" - update_schedule: "daily" - commit_message: - prefix: "chore" - include_scope: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1881b89d4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + day: friday + time: "12:00" + timezone: "Europe/Zurich" + commit-message: + prefix: chore + include: scope +- package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + day: friday + time: "12:00" + timezone: "Europe/Zurich" + commit-message: + prefix: chore + include: scope From 84811f46a381f3d399ef2707efa830ea0497ab53 Mon Sep 17 00:00:00 2001 From: Fabio Ambauen Date: Fri, 21 May 2021 15:44:43 +0200 Subject: [PATCH 828/980] chore: remove auto-approve-dependabot.yml --- .github/workflows/auto-approve-dependabot.yml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .github/workflows/auto-approve-dependabot.yml diff --git a/.github/workflows/auto-approve-dependabot.yml b/.github/workflows/auto-approve-dependabot.yml deleted file mode 100644 index 70b753774..000000000 --- a/.github/workflows/auto-approve-dependabot.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Auto approve dependabot - -on: - pull_request_target - -jobs: - auto-approve: - runs-on: ubuntu-latest - steps: - - uses: hmarr/auto-approve-action@v2 - if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]' - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" From 84d5f44f417428157c79bcf33273241dad24cc08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 May 2021 10:08:10 +0000 Subject: [PATCH 829/980] chore(deps-dev): bump ipdb from 0.13.7 to 0.13.8 Bumps [ipdb](https://github.com/gotcha/ipdb) from 0.13.7 to 0.13.8. - [Release notes](https://github.com/gotcha/ipdb/releases) - [Changelog](https://github.com/gotcha/ipdb/blob/master/HISTORY.txt) - [Commits](https://github.com/gotcha/ipdb/compare/0.13.7...0.13.8) Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 374d5f7b8..076c84046 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,7 +9,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.6.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 -ipdb==0.13.7 +ipdb==0.13.8 isort==5.8.0 pdbpp==0.10.2 pytest==6.2.4 From d9ec053c42a8c81bead7651742f4f7b80ef11d11 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 May 2021 10:08:12 +0000 Subject: [PATCH 830/980] chore(deps-dev): bump requests-mock from 1.9.2 to 1.9.3 Bumps [requests-mock](https://github.com/jamielennox/requests-mock) from 1.9.2 to 1.9.3. - [Release notes](https://github.com/jamielennox/requests-mock/releases) - [Commits](https://github.com/jamielennox/requests-mock/compare/1.9.2...1.9.3) Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 374d5f7b8..18ae28929 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,5 +20,5 @@ pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 pytest-mock==3.6.1 pytest-randomly==3.8.0 -requests-mock==1.9.2 +requests-mock==1.9.3 snapshottest==0.6.0 From 655262d468922cdab92dc67b1140a0821e07606a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Jun 2021 10:05:01 +0000 Subject: [PATCH 831/980] chore(deps-dev): bump pytest-cov from 2.12.0 to 2.12.1 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.12.0 to 2.12.1. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.12.0...v2.12.1) --- updated-dependencies: - dependency-name: pytest-cov dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c9769cc60..0d2d182b8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ ipdb==0.13.8 isort==5.8.0 pdbpp==0.10.2 pytest==6.2.4 -pytest-cov==2.12.0 +pytest-cov==2.12.1 pytest-django==4.3.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 From f1f46da8cc7046f857ffca1e47f8486fab73611c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Jun 2021 10:05:02 +0000 Subject: [PATCH 832/980] chore(deps-dev): bump black from 21.5b1 to 21.5b2 Bumps [black](https://github.com/psf/black) from 21.5b1 to 21.5b2. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/commits) --- updated-dependencies: - dependency-name: black dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c9769cc60..dcc092cd6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.5b1 +black==21.5b2 coverage==5.5 factory-boy==3.2.0 flake8==3.9.2 From 74c081038b0e4413637e35dc5bf6267c02e990c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Jun 2021 08:10:32 +0000 Subject: [PATCH 833/980] chore(deps-dev): bump ipdb from 0.13.8 to 0.13.9 Bumps [ipdb](https://github.com/gotcha/ipdb) from 0.13.8 to 0.13.9. - [Release notes](https://github.com/gotcha/ipdb/releases) - [Changelog](https://github.com/gotcha/ipdb/blob/master/HISTORY.txt) - [Commits](https://github.com/gotcha/ipdb/compare/0.13.8...0.13.9) --- updated-dependencies: - dependency-name: ipdb dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c9769cc60..322e9c161 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.5b1 +black==21.5b2 coverage==5.5 factory-boy==3.2.0 flake8==3.9.2 @@ -9,11 +9,11 @@ flake8-deprecated==1.3 flake8-docstrings==1.6.0 flake8-isort==4.0.0 flake8-string-format==0.3.0 -ipdb==0.13.8 +ipdb==0.13.9 isort==5.8.0 pdbpp==0.10.2 pytest==6.2.4 -pytest-cov==2.12.0 +pytest-cov==2.12.1 pytest-django==4.3.0 pytest-env==0.6.2 pytest-factoryboy==2.1.0 From 7e7424a553bdbd055e845c2edad1412766bbb5a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Jun 2021 10:04:24 +0000 Subject: [PATCH 834/980] chore(deps-dev): bump black from 21.5b2 to 21.6b0 Bumps [black](https://github.com/psf/black) from 21.5b2 to 21.6b0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/commits) --- updated-dependencies: - dependency-name: black dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 322e9c161..58a363a4c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.5b2 +black==21.6b0 coverage==5.5 factory-boy==3.2.0 flake8==3.9.2 From 08fcf3d099652a8acfe82c5b24f140d65a16cbee Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 16 Jun 2021 15:33:03 +0200 Subject: [PATCH 835/980] fix: refactor command into a migration use django migrations instead of management command for migrating reviewers to assignees --- timed/projects/management/__init__.py | 0 .../projects/management/commands/__init__.py | 0 .../management/commands/migrate_reviewers.py | 18 ------------- .../0012_migrate_reviewers_to_assignees.py | 26 +++++++++++++++++++ .../projects/tests/test_migrate_reviewers.py | 18 ------------- 5 files changed, 26 insertions(+), 36 deletions(-) delete mode 100644 timed/projects/management/__init__.py delete mode 100644 timed/projects/management/commands/__init__.py delete mode 100644 timed/projects/management/commands/migrate_reviewers.py create mode 100644 timed/projects/migrations/0012_migrate_reviewers_to_assignees.py delete mode 100644 timed/projects/tests/test_migrate_reviewers.py diff --git a/timed/projects/management/__init__.py b/timed/projects/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/timed/projects/management/commands/__init__.py b/timed/projects/management/commands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/timed/projects/management/commands/migrate_reviewers.py b/timed/projects/management/commands/migrate_reviewers.py deleted file mode 100644 index 92701e5bd..000000000 --- a/timed/projects/management/commands/migrate_reviewers.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.core.management.base import BaseCommand - -from timed.projects.models import Project, ProjectAssignee - - -class Command(BaseCommand): - help = """Migrate all reviewers from the Reviewers through table - to the new ProjectAssignee through table""" - - def handle(self, *args, **kwargs): - projects = Project.objects.all() - - for project in projects: - for reviewer in project.reviewers.all(): - project_assignee = ProjectAssignee( - user=reviewer, project=project, is_reviewer=True - ) - project_assignee.save() diff --git a/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py b/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py new file mode 100644 index 000000000..c67404648 --- /dev/null +++ b/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py @@ -0,0 +1,26 @@ +from django.db import migrations + + +def migrate_reviewers(apps, schema_editor): + """Migrate reviewers from projects to assignees""" + Project = apps.get_model("projects", "Project") + ProjectAssignee = apps.get_model("projects", "ProjectAssignee") + projects = Project.objects.all() + + for project in projects: + for reviewer in project.reviewers.all(): + project_assignee = ProjectAssignee( + user=reviewer, project=project, is_reviewer=True + ) + project_assignee.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0011_auto_20210419_1459"), + ] + + operations = [ + migrations.RunPython(migrate_reviewers), + ] diff --git a/timed/projects/tests/test_migrate_reviewers.py b/timed/projects/tests/test_migrate_reviewers.py deleted file mode 100644 index eafc6eb2d..000000000 --- a/timed/projects/tests/test_migrate_reviewers.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Tests for the migrate_reviewers command.""" -from django.core.management import call_command - -from timed.employment.factories import UserFactory -from timed.projects.factories import ProjectFactory -from timed.projects.models import ProjectAssignee - - -def test_migrate_reviewers(db): - # create reviewer for a project - reviewer = UserFactory.create() - project = ProjectFactory.create() - project.reviewers.add(reviewer) - - call_command("migrate_reviewers") - - assert ProjectAssignee.objects.all().count() == 1 - assert ProjectAssignee.objects.last().is_reviewer is True From b87719485affd6421734251c270d1fbeb37a7176 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 17 Jun 2021 14:29:27 +0200 Subject: [PATCH 836/980] fix(reports): udpate workreport template --- timed/reports/templates/workreport.ots | Bin 11634 -> 13002 bytes timed/reports/views.py | 13 ++++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index 952604e4e30049e2b7f0ec7f1916183a52ae7077..4b488e54031e2b4beb90d70f9f6e4deeffee3b9c 100644 GIT binary patch delta 11327 zcmb8Vb983Uwk{moMh6|cqmFIcMh6{Ro#ai@v6DBpZQHhO+veB(JA0qI&-v~@caB

62qW!nqgXVv@{|x#5kFO3?^dwYyH4@K>smxgW~~! zqc*FID58(=aDu~XIeMm6LZ({`DYBth!Qf72Lwg#A(m*iDex#|WN)g^22t6lCq)DC& zf|53VB9kk)0l4x7Lp(}DkkFqDD(y&_jhi_pZt|URFLo#hFpFipeh$;9I+#wyBe5%X z42#=gj);OsPF($kJPQS4nz4IDVr>8i)jOIkC-g{MuNy9_2$GhX9yC>gZnVK*8v$AR zA#Uk6joxRvfEUMd?G~-_=+1P73Db(75qB77Fl2Z4^isPRvSkY%fv?n1?Z9v_#KYu= z!q0d{k3-S$P3%Ckujy;&^)oZHrcgACsEUV#Q*^^KRNaamjk55u2C>_2F`IyG(a^*u z5q0K3ny!FTEZk~#itZUJ5&i)Fnzrv9Nbk+`9p4hV;9ld+AMojfC9| zS!EgifjE{;()_*xH=HXu^Q&FYyMngBgnAg;zHyXp*xvVD09$Sxd9_6@!`Tm#@1msL zKloU!*+z34E{-kUXH6s3+oPgjF&Z^aJ-x zSdV)Ab)kNrxCTb{Bo~@M&l%6#0DH#ZlIhj$m-nsWg2ZCYC<6^kKjmVK3GvIXF-m*4 zpC5;}+^1`8S^(#6KOZ-@eU@)wc64PbYszVOU0(`I{dWNvP%#S3%!HLVQj#s9ivF|l zS88&x0ZpvzLS(e78#ax#=SBl==75hMqny%P+=hvB_cR@#u_$McI2s;4MjFb*=iv0~166TcsMX(jd(kU~E z5X#nqk}HjLF!eceB-LGM{|Y$B^ei7CInT=(gIFVQA$`r&AhFBfB;VhgC13DZp~?O zVjBR8Pdsw5!zL_+HV`afP!5arzCAsS%-gcLRZR`a6XW$8oZwKcranAY&r7+-FC>rd zoK|~_O109ryuKhxLwJi+P}5cK<}<-luhO^VQW`HUqKU{UV#0NKjY403n-Po6&`Zuj zi3<5cS2T&HQDv^~mbCYG=IYnj*ITeEtF!>*AtMQMXMZ((?QsjURSXx}!W}3Tb;CI4 zWA4Vl8c5CjG|}nAovV<2`mwSs%%l;1IG)zfGYC3jKE3mEI3TBlPJ^&uZf*~DhiZ>% zSp!z(#~7q<2V*9fMRX6^C}pugb=lCw*$+-vNgZE`)i{PyC1+w>u!bBeBo>UGvZ#50 z4|KhR-PF~1l&Q8qBxlr)%MlG8y*Ti#d733afcN^qSiS6wlBxqATL;ZrLfeV+epY-x zpYUlB``J|Kj2bZ$V|qV{o+P&^RHE!*80ExvT(>+=?Wp07wjSzU#y-@HtHu}C1r78@$#u`uADqSjv?2?I_}3w&88=eJ=#|b zatts+nVXh6USMv~@p5!6ouW9Yi4)=&g^ltyYfz0q0ISKAXd4U|G@nB`DT$Sr zAj%<(C>8ZB<%6X6LYpXSuRh?G8Kd&s=#Yq{5PVm;*xrQ;b{$L8rfL{X4`~qq1i#$I zz9feZNgAE(2X0P0q%)P-`_P7Q-|LSBr;51Ltjl5LWA11x`SQzB(j)GQRkxCi^9s+& z*e~cZo27bf?;ARw_nV)HiDBZH9kuOnnKdWUB{GvxAHcZ2+UwXPN0;?SHd}?U_44h= zg$fHF54tTz?1_(XwEdxq8$#@zIgZ^{Vb|Nc#ST@Wu}&_fb^17K}Ld> zP$(6wF|7%1BE7S~MV(zFdN{-qDASkT5Ox99a_j8&axO9*^1GS4s1P}HBlNV@z2Y0sDN$2ML^5LlE2Vi zQPgt7d)wecl06l@?rPYzS6OD1Ha~#k^bor4gT?%|NTnvoq?a21l3bhmdq%FoBjU|R zG`{GW_B`LGij%o zqv~muLS`g-Iyl!gfiVfd8?P$poQ62bXi-7a!_H#kH{ml&7&sn0kNk1) z_lVFW&{+EG{c(*rST+Hsytxh(J$SO3I|bYiaN`fX8ug2TJI!VE%Y zO#U62D;kkVNGZjq0-qWkLV~!i8RA1^Wjr1@)~kZ)g%(RTK+VmF)J z9F@W9W!^Q+65-S)AFWlG;u6+i;JA9`aiC|<@zZ;nxa#i?thcT75-pYY_obBB-?jQj z?7phsp63eZ!Lwojd<`7A{L|+VF{x5%`q^$KFhUt{tHxn zSb1QhBE1F3l6xtxv8+Ug1<2Ed(v#o5!&~*?L&F>+fy~_7f%U$MI$er zR^E0(pw`Ky=mTa!^Qw)ECGz#8;h*a~tHOLYipXjJbF<<@;$|0G6UF-($(DoF^NqV0 zS`t0?{D|dtb=q}gGdb(Pc*mBM&Xn|4vxymJd4lFXDX&DaM^Urc4ocYNcR%IUu?m(V zv(D4~nu+Kr0;;6*4=v-z&%(i%&%fzM9rJ0v{GWuDn)!dFA4(v91SpDx3s`r|ZNLI_ z0w9IWF3$K2x;9;Qoi1b>W*p(@fJu3s=gfo)_dHv_ltv<_02Eq_xu%CroZW$>Z&o_rwUU7 zyhc9MaT>Ncsa%Ij^tts>7y98Ues?G`b;v7fXfWy^MLbz_`eR-w7r{NsyH_8rM z4rdiXn=bh1oODHA!153f_LlIq?UwPSC$6i&gs|6pzZxzc9hFEX0<`%g+nx8cp``lg z>QbjAaB@$|`Q?Ihh@ZWwJch^__`u2*H}jt`4E%&&nfVQGc{&<$yGEFo#lTWHetV(2 z2Z#>>&6Jg7Z@M6PotkIU*uiKkriHS_*<^Gqi0?)q*@260$QQ&#}QoP>2&%Zl#aaNe=ikAj@)b7S2*nLoLJ+TX*95v{kn(VYkpg zm!B8HK4Uei?K@dL!5GbcErPMdg&fpeB(bUQmuBbKwAP}mHNdft?vWqWzTGu0XspI} z%4`K8kaek&E}hRYivGdFR?nrydUMVm;jpYMm*VhZ`0jAtW0yWh1iovJXiSa{Pbx#4 zlj}SC!dm@}V-@mhlZz%smCD&#K?IJuT3a<*&M(RBxp0?Xh%?$I{4r&1>6e@g6>N%0bDPbs83OU+Xn;; z+ID_spbI(!4Mt3koW`z7ke zj#N~g;tV03A*}ny7ZHrwHSq+By&v7weDEzAOJi8lRe-RZJ{NcP8_)0iW5^bus~E)D zFo7db-xeYKDp&Ao;*7zbXNvp$J%gitFcyx&MsiJ!w;nu3Y*&P@#6OO0&;@_J$ zn+^?leZ@x}oAz8nSj*$jjHIlUWU-t5@Z`d$KN#;CZ-K-Hfgb4^KSUI^*~r|+Ga{`) z6Thl}2nR&$DfTOS+QG-v=6haebEehI%acY56>a;FAUA1cc!95>un9601I)g%W6w$g zhRFM`dG>0bm%FG#@U|1;CTi`XG@utU`^lZ}+o<%DYCoc-LoeKi4rdMj^u>H0qsqJw z)={ebH8~mBMP0;jaGW*R;wiz}QUCKNpcXVn8X&E;&l+lrWrYF7z(EE{8Z+<;*yBzK zvZfd&$J6Bx!qXQadqiDv9MT`K2$7KNs1|Omg4SO0D@!(+=d;=7-Lu35!s2J{tFs_! zPlzGqSS^g168Pl`tl667SArP^25)Mr`LZDj)1#3m?r{2wZ-mc4VMz5w6ySuKq0i)aG`_r5J?ArtEoEZa7!r65scFjta8Gf{rbY6$`%C58+jgk zrenOwz?z$;0sa-tdn7hBym{u>LeKlx8K9>+PBCq`ytWxzZ zQKVZwV~&m7I+zKLS`-tbawAq)bneZW#~y*cm1~oHl(-o7|P4{3a`c6`n6H|4Dm*?Htg$QiC6!JCd}4xV zSe2c_tkv$V7p?K*pCj5rXODV1JXWqFp3^}AD<{UXaf{xpmaZVy1)3#lsDLyEuST+D z4dYikPhA>fl%;JfArCLi&8o1;y$0UEN0Q2@^u6eol`zq&n12gp{p zO7iCAs0fr&-7H_>3R7A4-pKt3t_1cAjg35G_u0t(@jngueex(tqZx&;Q~Emflf5!> z*bUXPL47GILF1YdpX)mbz@`j@4a9d37~*fGbhKYoCH3Jel8kSjW$fPZDfHuiU2DC@ zXN2n>@Av21}kt?DF^kUlVQ zI|m$SoSr(cPUr|$7sI|9`3CVh!Bwm782Y@C?hvB#oMCWD7ljeQ4fttN9-Dg-`;q_U zKA78X_KrvU9d~{DT5!vYYTZSj94yK2eKD;6B$nxARoB6_9C`hs`SLn?Z6@w9`cu|{ zg=GP8BXPA3DfCABs$2TB{k#fq@!UqdymeJOu))hWy?)?&7+%NI#};f9nS;de7cl_` z^Zaf_3M2Ai{iJa3NC1N- z0sc~@7o`X&FoLJZAN2F}H0*37Y%~qEaDcm}I;yxv?&ifvtwNy1o++z8Io?8rH0meI z)<75IN@8#AOksp&p;zm}j!i_+a}<^cgGRAitl18#gwFKiagn+(2qnMM8BJ+tc`&oL@A`hyEw z8J|A*pSU<5VSsNQ^U{bO+x0~NrXZB|NLx5RgI$hc&o6(DTK!lE!79I!8AWr-y4#D@D; z>JJOhNZSR1a>p010C*s(@@LD{Tu+@=**6mjJ2V6aP{|vU*RbgwK|_RfMNAu$eKlL( z5}x&5P6Oo%hkQj7xqP8Rn)Vc6IQ{j9j{UI-qzWYDU~s6Fx=_ENZ3*gO$i=)wpA}&0 zM=^-?u&F2D#2l`U>A-X*jRzV&k+;1FEz_maV!$mQUD#0N1MW<+dN$0l5Es7FXRg0_ z;}X1csa2oTkoGrh>L-g$#<{pG(RVu4>W&`lqQM?fuj*KRqcv>AEq1W?HFz?rAIo&$~8o~7yA6woEM-wl!>_MW6s83 z1`W4~L9&wE~p3`sUx&jf9wMQm2`jeh_ zf+S^^K%g*1G!W}0ULJJQ4nnI7rX6~S4luw&X>%dB>5smM)PS9+I?*S~ z=6k6ysseLD4r8Twl8i>K7^Rja?DN}ePBz-Ia0O&6ORDlRM?Vv0BXhQDV#@oVfw~_h zT1K)8Bm?J5U(A4i1#IGuB6ApZfg$>YPi-jhJ7m%z47c6&{V|3sHI1JPKo2De+09en9Wj)!f&uCx06Ms&^#wgbUxzIK&M=J`9r!QNG)L zH)+bXI`eS_g4BOKo;n`j1G+EUIon_C!4@~XdCR0DmGRqC5tv39z={pa%mGH`#U%SP z5P*zwD{*DfmHk=~4gpl?(cBS6%225l4ei?(4i*iyV$z+MiGfU5QKRyEAMi!wXq!kx z1+|K&c3&UZ)GXi9B=oKk<8z@+S@5<97`MmgCLEA6_blnOu~{N0dym}L{174fs2k9o z?no`uj$d2B`@AZ*w`6zvfkRK4)Gkww2N-CpXMkBQ?oUv}-) zJx7lmzJlW>T>jJLeqjYAk}vKt=rmMaA=8)k&{y&65yB{Z(486(b%93j*Y;d~`@)Kn zSL5kuvo;Y1UF86Q*Oft(1+Da;uWIGH)1dXZ8i%XaQX7j~DOzAEXF$9vQJ{^N8o->_ znJ7&xRofVT)gf)}dilm|uxdE+;*-|7fvHC>E6KRCf<^WY99ezIP&ZPAXZ>d0v z;UJJv?>Wh%?&Iy7x6DZ?{aFS_M8IZLB%cd3#FLLOspLlrMSnQJKsk3ztO8!r1PRB^ zpG$ti%X1du2bNDG!I|~zDDU(OCLYx@Q&Idth_zQ2+2g7ij`rMH|lblAl#r(By5Fg!)~)iEnZbV2Rv6+U;va4pq807 z$y@@z(at`T2jM3~zsbjo6fhrGI69NO4BT_bBHRP|SRJhOIyOf|cZgUWm*bkS9-;rx z_fkqf0wCso&zTxIq8*gl$UrCwshlx@n&E+dp2065mJ`~Bjv@}R)%AOU48qz)ld}w# zmp}c?Y4Wn++sIx1ZNbbus|aI)O}@|-bUm#P$CbYf=IYUKhr2B${-c=kvHstqfLya& zwjUA%1fBffN5Q}T5GeuQK#*{eK|pYkfw}}tfa*V|!6(cgoF614fAACp1ngZwMnx16 z5&{th5&;1L910Bqg8%~>6ax+#6&V=^5fK{!2L}TS5tkAhmmG_j9`7qXE;$<+Dij$y zA}tm;85SlJAvy^mF%>cXcVYx~G7NGuN-`QQdPY_{R$gWra%Os39tJWV7FtdY4gv;2 z0LeE2N_I&aUL{s;F$O_3UQti=m9W znTENUnTe&7g}sZDm5H6LlbgMTo13eerH`(CfVpe1qh}Dn2Vm(F=jk8n5|rc`o&^9F z`B-ZP*z5XQ8wNTW|8}tmaWaf_{}Jk88R+E#^fC|e^GNWuP7HL(j&LuC@d^k81O|u2 zhQy}ChyPBA4NXi+{vDU+6JO$=R2G(y8=g`SmRb^$UJXnu2+wXv%q~tWY%ER;C`ga3 zN((N@PH4!EsLca5mS?u)$Fvv6wU(x2XJ?mImlxMI*OnKy)D+j%)z;Ow*0nU&Hn+Ak z)V4L(w6?Y;m-nPs_ZKvclyuAh>e>e?dgkl9$J)F5+xth`MkgBv7n>(`+NZX9%993b zGlm)q`r2xzJBs^z+eUknUu@a)X=^upTg%KFN}%=*gQ%Ia$G-0r~A!3bb+cXVZcZ2e?@ zeRp+hePZi;eEVj3_jq>iX6@i=_2h2#?0NI%aesMucWr)Wcl~T<{%mjc;&kI?ckbq3 z`Q~Wx_GIn#Wd8kPd2erLd;etj`0V6h=j?dz^z?M+>~8zw<@o&i=e15`=Z-s+^z%NRQ2&%ZP zoUi{VRkV0J~IrodgB2tMuuW*qT?ZfK-0)+=YJp>Ue0pl+em}iy(LK0jLHRtzKwO+AR3VG;F#e zu?*XdJ=?c?KTceYA3;^Ht?9^H(PwV~XQzytT>H=*rmOk2l#FoNHi@!Z#nsl1AM6iz zYq!cZLE?HH`_I#Mx#eZuoM)1}eO4n~^LBqdL|Ct$lBQugvK@KxXdRL#zk5=gB1O2% z-47)y(w-T82duq$H7`$oi*vKI@cFeI@tnbD;bs~W)%scxgIO6e6wTM_nr7n;QI_oe zH2vbjekS^>E)xzjz`cPkQLrXYauI^48iN80 zLKaE7d0)s?TUkVH>HKkwTbU|c^b}$(XvCzH`&%X;CWD%w1-#<8uo0QqKgGN0H!Gg5 zj1{2IJ`NLEcr9@EX}R@sj7uFwJUMGO35Jzu2kjnDFH?hJvF}#3I^UVeC|y9sDcZ|u ztYcT`0O_LoG)M~h5JbOP{&N7?eiUt%w3mu)fTIku33KHJo;rg%(ii;2n1oP#mB$rCwuKktI3eJswmO^FUBb zex4YVl>UwxVcNLavUG%JBpUAazRv#HM$(#URGTEPaw zx09l-f#H!p+n>)Gz|+so-TVAI^9~+LQ#YTVM__6#m4_qmph77zt!hyq&A?r4sT5FW z0Noi<7`2?E_s;NXboY_IxI}Bya5>>~F2X+^&;SIXY{`Cf!td9+o)rE>Dj^o;!^3{c zrg>-Vqjn2ltMBtPRhyFF!`SLL6cHBKs z5Em9Ig@>*`b7aBRB%sFl{oB4#IU_)u;liwsle^QTwJB`*L1D_+pYSMu=(PQ}6?X13 zPRJ*6AG=8tRg6P14lu=`M!5Kl|0aI)M?gUu=l61;7PPKd8NHbYeAraL$3ByhXt7H# zHE=X|RmNbo9XsW9`Yfy$iA>ay_d}!vqk`vk&JHNb&sV?PH#UAMqjf1xQajVVT3r37E9BSqHoq z^j1~PoUxJ2ed^@CUjh7&3kW{e7WfS>IhuS!%+=p9)lq3UNTkUyOU5rlI!9tLwa95O zePpR$%^f?NS;I^;eJwVw)S`Ec)JtbdoEefaoi^VYtg%3nXqTxg`q7@bX5hTW$QIb2 zTsf^zjV-vm@LZ<^W^5k0f2&{Zd~uSJWCJ zO=?q(*C|ZzrS}srRRX?5Od`vBsVbZDmra6NrOU}w(bMKk2>$x|h=uLJ-NgE-T!i{EfdZ@rNK=&*}`*yvLytBVU10?(8wplFU1~R@E(-k166?~$Nwq?U7>m_2fNI! z{&raXN>eh^=?tiT*8a2R2A6E-+F3kc)KP9>hv=M(%Ys9@n+v+EKD3U_Bv-uB>Pzk2 z6Y|)C=9$>w%QbAVaZ^9y#jqFVMT39fO+&JUQPbp-kfYiTry}B|Q|RerQdFjTJCgqD z&)XG>jt5JDz=vEA#Q6e&Ch-ue2r2mQO~AC+4FJmWrgLQJOKTcX-n2Ktl2q1db5GIU z>ZblaY5ScX`HtEQrpfuEGuYC<-w*EPNwQQxoTw6DXRo`Lp(|@WFj)>&Qe91Gw-fiy z?(5Wke&k*i$I+U^&_Z;Vx`GsK4dK;NKCLn1L{wb>#^wIq*bF3oc9+vUf3K?q<^SPhg0CC zVJPCU9srQpbXnm;!$Th-^pR|23?;jBf&kT?9eBdl&@l2zX_08^-PoT|PDOHEWK4{B zK73M*3qlT;2LL)w1;(A++&`NZVp@HDQ7Jn?>wSGs zZ9C}@w*s!;l5xaEq11njSekC$kteoeNeXfY_0Gy5gjCsKNuHVtMn|GIG6&t)rn1e< zAM7f>{yVq{<(RC0477hBWbNwPU>YN>qcDjol$LI Q*`sDn>EMe`w76uxdO0FGpi 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 P+LT;2TDTLsKkWYjkXW~u diff --git a/timed/reports/views.py b/timed/reports/views.py index e71a0caa6..f0ddda425 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -223,16 +223,15 @@ def _create_workreport(self, from_date, to_date, today, project, reports, user): table["A13"] = Cell( report.date, style_name=date_style_report, value_type="date" ) - + table["B13"] = Cell(report.user.get_full_name(), style_name=text_style) hours = report.duration.total_seconds() / 60 / 60 - table["B13"] = Cell(hours, style_name=float_style) + table["C13"] = Cell(hours, style_name=float_style) + table["D13"] = Cell(report.comment, style_name=text_style) + table["E13"] = Cell(report.task.name, style_name=text_style) if report.not_billable: - table["C13"] = Cell("no", style_name=float_style) + table["F13"] = Cell("no", style_name=float_style) else: - table["C13"] = Cell("yes", style_name=float_style) - table["D13"] = Cell(report.user.get_full_name(), style_name=text_style) - table["E13"] = Cell(report.task.name, style_name=text_style) - table["F13"] = Cell(report.comment, style_name=text_style) + table["F13"] = Cell("yes", style_name=float_style) # when from and to date are None find lowest and biggest date from_date = min(report.date, from_date or date.max) From 5d3d3dd966e860e3da01a68be7ed244c038e5945 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 24 Jun 2021 09:29:29 +0200 Subject: [PATCH 837/980] feat: add or refactor permission tests add permission tests for external employees add or refactor tests for internal employees for new roles --- timed/conftest.py | 46 +++ timed/employment/tests/test_user.py | 69 ++-- timed/projects/factories.py | 45 +++ timed/projects/tests/test_customer.py | 23 +- timed/projects/tests/test_project.py | 40 +- timed/projects/tests/test_task.py | 99 +++-- .../reports/tests/test_customer_statistic.py | 6 +- timed/reports/tests/test_year_statistic.py | 4 +- .../tests/snapshots/snap_test_report.py | 12 +- timed/tracking/tests/test_absence.py | 87 +++-- timed/tracking/tests/test_activity.py | 148 +++++-- timed/tracking/tests/test_attendance.py | 51 ++- timed/tracking/tests/test_report.py | 368 +++++++++++------- 13 files changed, 680 insertions(+), 318 deletions(-) diff --git a/timed/conftest.py b/timed/conftest.py index 2e9f5c9a5..5139edadc 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -61,6 +61,34 @@ def superadmin_user(db): ) +@pytest.fixture +def external_employee(db): + user = get_user_model().objects.create_user( + username="user", + password="123qweasd", + first_name="Test", + last_name="User", + is_superuser=False, + is_staff=False, + ) + employment_factories.EmploymentFactory.create(user=user, is_external=True) + return user + + +@pytest.fixture +def internal_employee(db): + user = get_user_model().objects.create_user( + username="user", + password="123qweasd", + first_name="Test", + last_name="User", + is_superuser=False, + is_staff=False, + ) + employment_factories.EmploymentFactory.create(user=user, is_external=False) + return user + + @pytest.fixture def client(): return APIClient() @@ -93,6 +121,24 @@ def superadmin_client(superadmin_user): return client +@pytest.fixture +def external_employee_client(external_employee): + """Return instance of a APIClient that is logged in as external test user.""" + client = APIClient() + client.force_authenticate(user=external_employee) + client.user = external_employee + return client + + +@pytest.fixture +def internal_employee_client(internal_employee): + """Return instance of a APIClient that is logged in as external test user.""" + client = APIClient() + client.force_authenticate(user=internal_employee) + client.user = internal_employee + return client + + @pytest.fixture(scope="function", autouse=True) def _autoclear_cache(): cache.clear() diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 79acce98a..a65704adf 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -26,13 +26,13 @@ def test_user_update_unauthenticated(client, db): assert response.status_code == status.HTTP_401_UNAUTHORIZED -def test_user_list(db, auth_client, django_assert_num_queries): +def test_user_list(db, internal_employee_client, django_assert_num_queries): UserFactory.create_batch(2) url = reverse("user-list") - with django_assert_num_queries(7): - response = auth_client.get(url) + with django_assert_num_queries(8): + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -40,19 +40,19 @@ def test_user_list(db, auth_client, django_assert_num_queries): assert len(json["data"]) == 3 -def test_user_detail(auth_client): - user = auth_client.user +def test_user_detail(internal_employee_client): + user = internal_employee_client.user url = reverse("user-detail", args=[user.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK -def test_user_create_authenticated(auth_client): +def test_user_create_authenticated(internal_employee_client): url = reverse("user-list") - response = auth_client.post(url) + response = internal_employee_client.post(url) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -77,8 +77,8 @@ def test_user_create_superuser(superadmin_client): assert response.status_code == status.HTTP_201_CREATED -def test_user_update_owner(auth_client): - user = auth_client.user +def test_user_update_owner(internal_employee_client): + user = internal_employee_client.user data = { "data": { "type": "users", @@ -89,7 +89,7 @@ def test_user_update_owner(auth_client): url = reverse("user-detail", args=[user.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK user.refresh_from_db() @@ -97,28 +97,29 @@ def test_user_update_owner(auth_client): assert not user.is_staff -def test_user_update_other(auth_client): +def test_user_update_other(internal_employee_client): """User may not change other user.""" user = UserFactory.create() url = reverse("user-detail", args=[user.id]) - res = auth_client.patch(url) + res = internal_employee_client.patch(url) assert res.status_code == status.HTTP_403_FORBIDDEN -def test_user_delete_authenticated(auth_client): +def test_user_delete_authenticated(internal_employee_client): """Should not be able delete a user.""" - user = auth_client.user + user = internal_employee_client.user url = reverse("user-detail", args=[user.id]) - response = auth_client.delete(url) + response = internal_employee_client.delete(url) assert response.status_code == status.HTTP_403_FORBIDDEN def test_user_delete_superuser(superadmin_client): """Should not be able delete a user.""" user = UserFactory.create() + EmploymentFactory.create(user=superadmin_client.user) url = reverse("user-detail", args=[user.id]) @@ -130,6 +131,7 @@ def test_user_delete_with_reports_superuser(superadmin_client, db): """Test that user with reports may not be deleted.""" user = UserFactory.create() ReportFactory.create(user=user) + EmploymentFactory.create(user=superadmin_client.user) url = reverse("user-detail", args=[user.id]) @@ -137,16 +139,16 @@ def test_user_delete_with_reports_superuser(superadmin_client, db): assert response.status_code == status.HTTP_403_FORBIDDEN -def test_user_supervisor_filter(auth_client): +def test_user_supervisor_filter(internal_employee_client): """Should filter users by supervisor.""" supervisees = UserFactory.create_batch(5) UserFactory.create_batch(5) - auth_client.user.supervisees.add(*supervisees) - auth_client.user.save() + internal_employee_client.user.supervisees.add(*supervisees) + internal_employee_client.user.save() - res = auth_client.get(reverse("user-list"), {"supervisor": auth_client.user.id}) + res = internal_employee_client.get(reverse("user-list"), {"supervisor": internal_employee_client.user.id}) assert len(res.json()["data"]) == 5 @@ -154,6 +156,7 @@ def test_user_supervisor_filter(auth_client): @pytest.mark.freeze_time("2018-01-07") def test_user_transfer(superadmin_client): user = UserFactory.create() + EmploymentFactory.create(user=superadmin_client.user) EmploymentFactory.create(user=user, start_date=date(2017, 12, 28), percentage=100) AbsenceTypeFactory.create(fill_worktime=True) AbsenceTypeFactory.create(fill_worktime=False) @@ -184,7 +187,7 @@ def test_user_transfer(superadmin_client): @pytest.mark.parametrize("value,expected", [(1, 1), (0, 4)]) -def test_user_is_reviewer_filter(auth_client, value, expected): +def test_user_is_reviewer_filter(internal_employee_client, value, expected): """Should filter users if they are a reviewer.""" user = UserFactory.create() project = ProjectFactory.create() @@ -192,43 +195,43 @@ def test_user_is_reviewer_filter(auth_client, value, expected): project.reviewers.add(user) - res = auth_client.get(reverse("user-list"), {"is_reviewer": value}) + res = internal_employee_client.get(reverse("user-list"), {"is_reviewer": value}) assert len(res.json()["data"]) == expected @pytest.mark.parametrize("value,expected", [(1, 1), (0, 5)]) -def test_user_is_supervisor_filter(auth_client, value, expected): +def test_user_is_supervisor_filter(internal_employee_client, value, expected): """Should filter useres if they are a supervisor.""" users = UserFactory.create_batch(2) UserFactory.create_batch(3) - auth_client.user.supervisees.add(*users) + internal_employee_client.user.supervisees.add(*users) - res = auth_client.get(reverse("user-list"), {"is_supervisor": value}) + res = internal_employee_client.get(reverse("user-list"), {"is_supervisor": value}) assert len(res.json()["data"]) == expected -def test_user_attributes(auth_client, project): +def test_user_attributes(internal_employee_client, project): """Should filter users if they are a reviewer.""" user = UserFactory.create() url = reverse("user-detail", args=[user.id]) - res = auth_client.get(url) + res = internal_employee_client.get(url) assert not res.json()["data"]["attributes"]["is-reviewer"] project.reviewers.add(user) - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.json()["data"]["attributes"]["is-reviewer"] -def test_user_me_auth(auth_client): - """Should return the auth_client user.""" - user = auth_client.user +def test_user_me_auth(internal_employee_client): + """Should return the internal_employee_client user.""" + user = internal_employee_client.user url = reverse("user-me") - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK me_data = response.json()["data"] @@ -237,7 +240,7 @@ def test_user_me_auth(auth_client): # should be the same as user-detail url = reverse("user-detail", args=[user.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert me_data == response.json()["data"] diff --git a/timed/projects/factories.py b/timed/projects/factories.py index e751c24bd..8c751a4c8 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -79,3 +79,48 @@ class Meta: """Meta informations for the task template factory.""" model = models.TaskTemplate + + +class CustomerAssigneeFactory(DjangoModelFactory): + """CustomerAssignee factory.""" + + user = SubFactory("timed.employment.factories.UserFactory") + customer = SubFactory("timed.projects.factories.CustomerFactory") + is_resource = False + is_reviewer = False + is_manager = False + + class Meta: + """Meta informations for the task template factory.""" + + model = models.CustomerAssignee + + +class ProjectAssigneeFactory(DjangoModelFactory): + """ProjectAssignee factory.""" + + user = SubFactory("timed.employment.factories.UserFactory") + project = SubFactory("timed.projects.factories.ProjectFactory") + is_resource = False + is_reviewer = False + is_manager = False + + class Meta: + """Meta informations for the task template factory.""" + + model = models.ProjectAssignee + + +class TaskAssigneeFactory(DjangoModelFactory): + """CustomerAssignee factory.""" + + user = SubFactory("timed.employment.factories.UserFactory") + task = SubFactory("timed.projects.factories.TaskFactory") + is_resource = False + is_reviewer = False + is_manager = False + + class Meta: + """Meta informations for the task template factory.""" + + model = models.TaskAssignee diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 491ba657b..9af9d6ed9 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -1,19 +1,19 @@ """Tests for the customers endpoint.""" +import pytest from django.urls import reverse from rest_framework import status -from timed.employment.factories import EmploymentFactory from timed.projects.factories import CustomerFactory -def test_customer_list_not_archived(auth_client): +def test_customer_list_not_archived(internal_employee_client): CustomerFactory.create(archived=True) customer = CustomerFactory.create(archived=False) url = reverse("customer-list") - response = auth_client.get(url, data={"archived": 0}) + response = internal_employee_client.get(url, data={"archived": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -21,12 +21,12 @@ def test_customer_list_not_archived(auth_client): assert json["data"][0]["id"] == str(customer.id) -def test_customer_detail(auth_client): +def test_customer_detail(internal_employee_client): customer = CustomerFactory.create() url = reverse("customer-detail", args=[customer.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -55,16 +55,19 @@ def test_customer_delete(auth_client): assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED -def test_customer_list_external_user(auth_client): - EmploymentFactory.create(user=auth_client.user, is_external=True) +@pytest.mark.parametrize("is_assigned, expected", [(True, 1), (False, 0)]) +def test_customer_list_external_employee( + external_employee_client, is_assigned, expected +): CustomerFactory.create_batch(4) customer = CustomerFactory.create() - customer.assignees.add(auth_client.user) + if is_assigned: + customer.assignees.add(external_employee_client.user) url = reverse("customer-list") - response = auth_client.get(url) + response = external_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json["data"]) == 1 + assert len(json["data"]) == expected diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 565a8a666..48bda94f7 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -1,21 +1,22 @@ """Tests for the projects endpoint.""" from datetime import timedelta +import pytest from django.urls import reverse from rest_framework import status -from timed.employment.factories import EmploymentFactory, UserFactory +from timed.employment.factories import UserFactory from timed.projects.factories import ProjectFactory from timed.projects.serializers import ProjectSerializer -def test_project_list_not_archived(auth_client): +def test_project_list_not_archived(internal_employee_client): project = ProjectFactory.create(archived=False) ProjectFactory.create(archived=True) url = reverse("project-list") - response = auth_client.get(url, data={"archived": 0}) + response = internal_employee_client.get(url, data={"archived": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -23,14 +24,16 @@ def test_project_list_not_archived(auth_client): assert json["data"][0]["id"] == str(project.id) -def test_project_list_include(auth_client, django_assert_num_queries, project): +def test_project_list_include( + internal_employee_client, django_assert_num_queries, project +): users = UserFactory.create_batch(2) project.reviewers.add(*users) url = reverse("project-list") - with django_assert_num_queries(16): - response = auth_client.get( + with django_assert_num_queries(11): + response = internal_employee_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, ) @@ -48,10 +51,10 @@ def test_project_detail_no_auth(db, client, project): assert res.status_code == status.HTTP_401_UNAUTHORIZED -def test_project_detail_no_reports(auth_client, project): +def test_project_detail_no_reports(internal_employee_client, project): url = reverse("project-detail", args=[project.id]) - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() @@ -60,7 +63,9 @@ def test_project_detail_no_reports(auth_client, project): assert json["meta"]["spent-billable"] == "00:00:00" -def test_project_detail_with_reports(auth_client, project, task, report_factory): +def test_project_detail_with_reports( + internal_employee_client, project, task, report_factory +): rep1, rep2, rep3, *_ = report_factory.create_batch( 10, task=task, duration=timedelta(hours=1) ) @@ -74,7 +79,7 @@ def test_project_detail_with_reports(auth_client, project, task, report_factory) url = reverse("project-detail", args=[project.id]) - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() @@ -104,16 +109,19 @@ def test_project_delete(auth_client, project): assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED -def test_project_list_external_user(auth_client): - EmploymentFactory.create(user=auth_client.user, is_external=True) - project = ProjectFactory.create() - project.assignees.add(auth_client.user) +@pytest.mark.parametrize("is_assigned, expected", [(True, 1), (False, 0)]) +def test_project_list_external_employee( + external_employee_client, is_assigned, expected +): ProjectFactory.create_batch(4) + project = ProjectFactory.create() + if is_assigned: + project.assignees.add(external_employee_client.user) url = reverse("project-list") - response = auth_client.get(url) + response = external_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json["data"]) == 1 + assert len(json["data"]) == expected diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 3a4e6c7ba..dc83c2527 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -5,13 +5,16 @@ from django.urls import reverse from rest_framework import status +from timed.employment.factories import EmploymentFactory +from timed.projects.factories import TaskFactory -def test_task_list_not_archived(auth_client, task_factory): + +def test_task_list_not_archived(internal_employee_client, task_factory): task = task_factory(archived=False) task_factory(archived=True) url = reverse("task-list") - response = auth_client.get(url, data={"archived": 0}) + response = internal_employee_client.get(url, data={"archived": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -19,8 +22,8 @@ def test_task_list_not_archived(auth_client, task_factory): assert json["data"][0]["id"] == str(task.id) -def test_task_my_most_frequent(auth_client, task_factory, report_factory): - user = auth_client.user +def test_task_my_most_frequent(internal_employee_client, task_factory, report_factory): + user = internal_employee_client.user tasks = task_factory.create_batch(6) report_date = date.today() - timedelta(days=20) @@ -43,7 +46,7 @@ def test_task_my_most_frequent(auth_client, task_factory, report_factory): url = reverse("task-list") - response = auth_client.get(url, {"my_most_frequent": "10"}) + response = internal_employee_client.get(url, {"my_most_frequent": "10"}) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -52,22 +55,34 @@ def test_task_my_most_frequent(auth_client, task_factory, report_factory): assert data[1]["id"] == str(tasks[1].id) -def test_task_detail(auth_client, task): +def test_task_detail(internal_employee_client, task): url = reverse("task-detail", args=[task.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK @pytest.mark.parametrize( - "is_reviewer,expected", - [(True, status.HTTP_201_CREATED), (False, status.HTTP_403_FORBIDDEN)], + "project_assignee__is_resource, project_assignee__is_manager, project_assignee__is_reviewer, customer_assignee__is_reviewer, expected", + [ + (True, False, False, False, status.HTTP_400_BAD_REQUEST), + (False, True, False, False, status.HTTP_201_CREATED), + (False, False, True, False, status.HTTP_201_CREATED), + (False, False, False, True, status.HTTP_201_CREATED), + ], ) -def test_task_create(auth_client, project, is_reviewer, expected): - url = reverse("task-list") +def test_task_create( + auth_client, project, project_assignee, customer_assignee, expected +): + user = auth_client.user + project_assignee.user = user + project_assignee.save() + if customer_assignee.is_reviewer: + customer_assignee.customer = project.customer + customer_assignee.user = user + customer_assignee.save() - if is_reviewer: - project.reviewers.add(auth_client.user) + url = reverse("task-list") data = { "data": { @@ -83,12 +98,19 @@ def test_task_create(auth_client, project, is_reviewer, expected): @pytest.mark.parametrize( - "is_reviewer,expected", - [(True, status.HTTP_200_OK), (False, status.HTTP_403_FORBIDDEN)], + "task_assignee__is_resource, task_assignee__is_manager, task_assignee__is_reviewer, expected", + [ + (True, False, False, status.HTTP_403_FORBIDDEN), + (False, True, False, status.HTTP_200_OK), + (False, False, True, status.HTTP_200_OK), + ], ) -def test_task_update(auth_client, project, task, is_reviewer, expected): - if is_reviewer: - project.reviewers.add(auth_client.user) +def test_task_update(auth_client, task, task_assignee, expected): + user = auth_client.user + EmploymentFactory.create(user=user) + task_assignee.task = task + task_assignee.user = user + task_assignee.save() url = reverse("task-detail", args=[task.id]) @@ -97,12 +119,19 @@ def test_task_update(auth_client, project, task, is_reviewer, expected): @pytest.mark.parametrize( - "is_reviewer,expected", - [(True, status.HTTP_204_NO_CONTENT), (False, status.HTTP_403_FORBIDDEN)], + "project_assignee__is_resource, project_assignee__is_manager, project_assignee__is_reviewer, expected", + [ + (True, False, False, status.HTTP_403_FORBIDDEN), + (False, True, False, status.HTTP_204_NO_CONTENT), + (False, False, True, status.HTTP_403_FORBIDDEN), + ], ) -def test_task_delete(auth_client, project, task, is_reviewer, expected): - if is_reviewer: - project.reviewers.add(auth_client.user) +def test_task_delete(auth_client, task, project_assignee, expected): + user = auth_client.user + project_assignee.project = task.project + project_assignee.user = user + project_assignee.save() + EmploymentFactory.create(user=user) url = reverse("task-detail", args=[task.id]) @@ -110,10 +139,10 @@ def test_task_delete(auth_client, project, task, is_reviewer, expected): assert response.status_code == expected -def test_task_detail_no_reports(auth_client, task): +def test_task_detail_no_reports(internal_employee_client, task): url = reverse("task-detail", args=[task.id]) - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.status_code == status.HTTP_200_OK @@ -121,14 +150,30 @@ def test_task_detail_no_reports(auth_client, task): assert json["meta"]["spent-time"] == "00:00:00" -def test_task_detail_with_reports(auth_client, task, report_factory): +def test_task_detail_with_reports(internal_employee_client, task, report_factory): report_factory.create_batch(5, task=task, duration=timedelta(minutes=30)) url = reverse("task-detail", args=[task.id]) - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() assert json["meta"]["spent-time"] == "02:30:00" + + +@pytest.mark.parametrize("is_assigned, expected", [(True, 1), (False, 0)]) +def test_task_list_external_employee(external_employee_client, is_assigned, expected): + TaskFactory.create_batch(4) + task = TaskFactory.create() + if is_assigned: + task.assignees.add(external_employee_client.user) + + url = reverse("task-list") + + response = external_employee_client.get(url) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == expected diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 5d2b64ad8..3be0b14cf 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -52,12 +52,12 @@ def test_customer_statistic_list(auth_client, django_assert_num_queries): assert json["meta"]["total-time"] == "07:00:00" -def test_customer_statistic_detail(auth_client, django_assert_num_queries): +def test_customer_statistic_detail(internal_employee_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) - with django_assert_num_queries(3): - result = auth_client.get( + with django_assert_num_queries(4): + result = internal_employee_client.get( url, data={"ordering": "duration", "include": "customer"} ) assert result.status_code == 200 diff --git a/timed/reports/tests/test_year_statistic.py b/timed/reports/tests/test_year_statistic.py index e2fc404cc..de19beaff 100644 --- a/timed/reports/tests/test_year_statistic.py +++ b/timed/reports/tests/test_year_statistic.py @@ -32,12 +32,12 @@ def test_year_statistic_list(auth_client): assert json["meta"]["total-time"] == "03:00:00" -def test_year_statistic_detail(auth_client): +def test_year_statistic_detail(internal_employee_client): ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) url = reverse("year-statistic-detail", args=[2015]) - result = auth_client.get(url, data={"ordering": "year"}) + result = internal_employee_client.get(url, data={"ordering": "year"}) assert result.status_code == 200 json = result.json() assert json["data"]["attributes"]["duration"] == "02:00:00" diff --git a/timed/tracking/tests/snapshots/snap_test_report.py b/timed/tracking/tests/snapshots/snap_test_report.py index f9b85226c..7ad0564ab 100644 --- a/timed/tracking/tests/snapshots/snap_test_report.py +++ b/timed/tracking/tests/snapshots/snap_test_report.py @@ -21,14 +21,14 @@ Comment: some other comment * Task - [old] Dickerson, George and White > Horizontal analyzing product > and Sons - [new] Dickerson, George and White > Horizontal analyzing product > Group + [old] Allen Inc > Cross-platform content-based synergy > and Sons + [new] Allen Inc > Cross-platform content-based synergy > Group --- Date: 11/02/1975 Duration: 0:15 (h:mm) -Task: Dickerson, George and White > Horizontal analyzing product > Group +Task: Allen Inc > Cross-platform content-based synergy > Group Comment: some other comment * Not_Billable @@ -43,8 +43,8 @@ * Task - [old] Dickerson, George and White > Horizontal analyzing product > Ltd - [new] Dickerson, George and White > Horizontal analyzing product > Group + [old] Allen Inc > Cross-platform content-based synergy > Ltd + [new] Allen Inc > Cross-platform content-based synergy > Group * Comment [old] foo @@ -54,7 +54,7 @@ Date: 04/20/2005 Duration: 1:00 (h:mm) -Task: Dickerson, George and White > Horizontal analyzing product > Group +Task: Allen Inc > Cross-platform content-based synergy > Group * Comment diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index dbb05dfa0..b6bca65e5 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -1,5 +1,6 @@ import datetime +import pytest from django.urls import reverse from rest_framework import status @@ -12,7 +13,8 @@ from timed.tracking.factories import AbsenceFactory, ReportFactory -def test_absence_list_authenticated(auth_client): +@pytest.mark.parametrize("is_external, expected, ", [(True, 0), (False, 1)]) +def test_absence_list_authenticated(auth_client, is_external, expected): absence = AbsenceFactory.create(user=auth_client.user) # overlapping absence with public holidays need to be hidden @@ -22,6 +24,10 @@ def test_absence_list_authenticated(auth_client): employment = EmploymentFactory.create( user=overlap_absence.user, start_date=datetime.date(2017, 12, 31) ) + if is_external: + employment.is_external = True + employment.save() + PublicHolidayFactory.create(date=overlap_absence.date, location=employment.location) url = reverse("absence-list") @@ -30,8 +36,20 @@ def test_absence_list_authenticated(auth_client): json = response.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(absence.id) + assert len(json["data"]) == expected + if not is_external: + assert json["data"][0]["id"] == str(absence.id) + + +def test_absence_list_external_employee(external_employee_client): + AbsenceFactory.create_batch(2) + AbsenceFactory.create(user=external_employee_client.user) + + url = reverse("absence-list") + response = external_employee_client.get(url) + + json = response.json() + assert len(json["data"]) == 0 def test_absence_list_superuser(superadmin_client): @@ -45,65 +63,73 @@ def test_absence_list_superuser(superadmin_client): assert len(json["data"]) == 2 -def test_absence_list_supervisor(auth_client): +def test_absence_list_supervisor(internal_employee_client): user = UserFactory.create() - auth_client.user.supervisees.add(user) + internal_employee_client.user.supervisees.add(user) - AbsenceFactory.create(user=auth_client.user) + AbsenceFactory.create(user=internal_employee_client.user) AbsenceFactory.create(user=user) url = reverse("absence-list") - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 2 -def test_absence_list_supervisee(auth_client): - AbsenceFactory.create(user=auth_client.user) +def test_absence_list_supervisee(internal_employee_client): + AbsenceFactory.create(user=internal_employee_client.user) supervisors = UserFactory.create_batch(2) - supervisors[0].supervisees.add(auth_client.user) + supervisors[0].supervisees.add(internal_employee_client.user) AbsenceFactory.create(user=supervisors[0]) url = reverse("absence-list") - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 # absences of multiple supervisors shouldn't affect supervisee - supervisors[1].supervisees.add(auth_client.user) + supervisors[1].supervisees.add(internal_employee_client.user) AbsenceFactory.create(user=supervisors[1]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 -def test_absence_detail(auth_client): - absence = AbsenceFactory.create(user=auth_client.user) +def test_absence_detail(internal_employee_client): + absence = AbsenceFactory.create(user=internal_employee_client.user) url = reverse("absence-detail", args=[absence.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() assert json["data"]["id"] == str(absence.id) -def test_absence_create(auth_client): +@pytest.mark.parametrize( + "is_external, expected", + [(False, status.HTTP_201_CREATED), (True, status.HTTP_403_FORBIDDEN)], +) +def test_absence_create(auth_client, is_external, expected): user = auth_client.user date = datetime.date(2017, 5, 4) - EmploymentFactory.create( + employment = EmploymentFactory.create( user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) type = AbsenceTypeFactory.create() + if is_external: + employment.is_external = True + employment.save() + data = { "data": { "type": "absences", @@ -119,12 +145,13 @@ def test_absence_create(auth_client): response = auth_client.post(url, data) - assert response.status_code == status.HTTP_201_CREATED + assert response.status_code == expected - json = response.json() - assert json["data"]["relationships"]["user"]["data"]["id"] == ( - str(auth_client.user.id) - ) + if response.status_code == status.HTTP_201_CREATED: + json = response.json() + assert json["data"]["relationships"]["user"]["data"]["id"] == ( + str(auth_client.user.id) + ) def test_absence_update_owner(auth_client): @@ -203,12 +230,12 @@ def test_absence_update_superadmin_type(superadmin_client): assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_absence_delete_owner(auth_client): - absence = AbsenceFactory.create(user=auth_client.user) +def test_absence_delete_owner(internal_employee_client): + absence = AbsenceFactory.create(user=internal_employee_client.user) url = reverse("absence-detail", args=[absence.id]) - response = auth_client.delete(url) + response = internal_employee_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -362,16 +389,16 @@ def test_absence_create_unemployed(auth_client): url = reverse("absence-list") response = auth_client.post(url, data) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == status.HTTP_403_FORBIDDEN -def test_absence_detail_unemployed(auth_client): +def test_absence_detail_unemployed(internal_employee_client): """Test creation of absence fails on unemployed day.""" - absence = AbsenceFactory.create(user=auth_client.user) + absence = AbsenceFactory.create(user=internal_employee_client.user) url = reverse("absence-detail", args=[absence.id]) - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.status_code == status.HTTP_200_OK json = res.json() diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index ec8d1883c..322f62e3d 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -1,9 +1,10 @@ from datetime import date, time, timedelta +import pytest from django.urls import reverse from rest_framework import status -from timed.projects.factories import TaskFactory +from timed.employment.factories import EmploymentFactory from timed.tracking.factories import ActivityFactory @@ -19,19 +20,36 @@ def test_activity_list(auth_client): assert json["data"][0]["id"] == str(activity.id) -def test_activity_detail(auth_client): - activity = ActivityFactory.create(user=auth_client.user) +def test_activity_detail(internal_employee_client): + activity = ActivityFactory.create(user=internal_employee_client.user) url = reverse("activity-detail", args=[activity.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK -def test_activity_create(auth_client): +@pytest.mark.parametrize( + "task_assignee__is_resource, task_assignee__is_reviewer, is_external, expected", + [ + (True, False, True, status.HTTP_201_CREATED), + (False, True, True, status.HTTP_403_FORBIDDEN), + (True, False, False, status.HTTP_201_CREATED), + (False, True, False, status.HTTP_201_CREATED), + ], +) +def test_activity_create(auth_client, is_external, task_assignee, expected): """Should create a new activity and automatically set the user.""" user = auth_client.user - task = TaskFactory.create() + employment = EmploymentFactory(user=user) + + if is_external: + employment.is_external = True + employment.save() + + task_assignee.user = user + task_assignee.save() + task = task_assignee.task data = { "data": { @@ -49,14 +67,32 @@ def test_activity_create(auth_client): url = reverse("activity-list") response = auth_client.post(url, data) - assert response.status_code == status.HTTP_201_CREATED - - json = response.json() - assert int(json["data"]["relationships"]["user"]["data"]["id"]) == int(user.id) - + assert response.status_code == expected + + if response.status_code == status.HTTP_201_CREATED: + json = response.json() + assert int(json["data"]["relationships"]["user"]["data"]["id"]) == int(user.id) + + +@pytest.mark.parametrize( + "task_assignee__is_resource, task_assignee__is_reviewer, is_external, expected", + [ + (True, False, True, status.HTTP_200_OK), + (False, True, True, status.HTTP_403_FORBIDDEN), + (True, False, False, status.HTTP_200_OK), + (False, True, False, status.HTTP_200_OK), + ], +) +def test_activity_update(auth_client, is_external, task_assignee, expected): + user = auth_client.user + activity = ActivityFactory.create(user=user, task=task_assignee.task) + task_assignee.user = user + task_assignee.save() + employment = EmploymentFactory(user=user) -def test_activity_update(auth_client): - activity = ActivityFactory.create(user=auth_client.user) + if is_external: + employment.is_external = True + employment.save() data = { "data": { @@ -69,21 +105,41 @@ def test_activity_update(auth_client): url = reverse("activity-detail", args=[activity.id]) response = auth_client.patch(url, data) - assert response.status_code == status.HTTP_200_OK - - json = response.json() - assert ( - json["data"]["attributes"]["comment"] == data["data"]["attributes"]["comment"] - ) + assert response.status_code == expected + + if response.status_code == status.HTTP_200_OK: + json = response.json() + assert ( + json["data"]["attributes"]["comment"] + == data["data"]["attributes"]["comment"] + ) + + +@pytest.mark.parametrize( + "task_assignee__is_resource, task_assignee__is_reviewer, is_external, expected", + [ + (True, False, True, status.HTTP_204_NO_CONTENT), + (False, True, True, status.HTTP_403_FORBIDDEN), + (True, False, False, status.HTTP_204_NO_CONTENT), + (False, True, False, status.HTTP_204_NO_CONTENT), + ], +) +def test_activity_delete(auth_client, is_external, task_assignee, expected): + user = auth_client.user + task_assignee.user = user + task_assignee.save() + activity = ActivityFactory.create(user=user, task=task_assignee.task) + employment = EmploymentFactory(user=user) -def test_activity_delete(auth_client): - activity = ActivityFactory.create(user=auth_client.user) + if is_external: + employment.is_external = True + employment.save() url = reverse("activity-detail", args=[activity.id]) response = auth_client.delete(url) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == expected def test_activity_list_filter_active(auth_client): @@ -115,7 +171,7 @@ def test_activity_list_filter_day(auth_client): assert json["data"][0]["id"] == str(activity.id) -def test_activity_create_no_task(auth_client): +def test_activity_create_no_task(internal_employee_client): """Should create a new activity without a task.""" data = { "data": { @@ -131,16 +187,16 @@ def test_activity_create_no_task(auth_client): } url = reverse("activity-list") - response = auth_client.post(url, data) + response = internal_employee_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() assert json["data"]["relationships"]["task"]["data"] is None -def test_activity_active_unique(auth_client): +def test_activity_active_unique(internal_employee_client): """Should not be able to have two active blocks.""" - ActivityFactory.create(user=auth_client.user, to_time=None) + ActivityFactory.create(user=internal_employee_client.user, to_time=None) data = { "data": { @@ -156,17 +212,17 @@ def test_activity_active_unique(auth_client): url = reverse("activity-list") - res = auth_client.post(url, data) + res = internal_employee_client.post(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST json = res.json() assert json["errors"][0]["detail"] == ("A user can only have one active activity") -def test_activity_to_before_from(auth_client): +def test_activity_to_before_from(internal_employee_client): """Test that to is not before from.""" activity = ActivityFactory.create( - user=auth_client.user, from_time=time(7, 30), to_time=None + user=internal_employee_client.user, from_time=time(7, 30), to_time=None ) data = { @@ -179,7 +235,7 @@ def test_activity_to_before_from(auth_client): url = reverse("activity-detail", args=[activity.id]) - res = auth_client.patch(url, data) + res = internal_employee_client.patch(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST json = res.json() @@ -188,9 +244,11 @@ def test_activity_to_before_from(auth_client): ) -def test_activity_not_editable(auth_client): +def test_activity_not_editable(internal_employee_client): """Test that transferred activities are read only.""" - activity = ActivityFactory.create(user=auth_client.user, transferred=True) + activity = ActivityFactory.create( + user=internal_employee_client.user, transferred=True + ) data = { "data": { @@ -201,22 +259,24 @@ def test_activity_not_editable(auth_client): } url = reverse("activity-detail", args=[activity.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN -def test_activity_retrievable_not_editable(auth_client): +def test_activity_retrievable_not_editable(internal_employee_client): """Test that transferred activities are still retrievable.""" - activity = ActivityFactory.create(user=auth_client.user, transferred=True) + activity = ActivityFactory.create( + user=internal_employee_client.user, transferred=True + ) url = reverse("activity-detail", args=[activity.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK -def test_activity_active_update(auth_client): - activity = ActivityFactory.create(user=auth_client.user, to_time=None) +def test_activity_active_update(internal_employee_client): + activity = ActivityFactory.create(user=internal_employee_client.user, to_time=None) data = { "data": { @@ -227,7 +287,7 @@ def test_activity_active_update(auth_client): } url = reverse("activity-detail", args=[activity.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() assert ( @@ -235,9 +295,11 @@ def test_activity_active_update(auth_client): ) -def test_activity_set_to_time_none(auth_client, activity_factory): - activity1 = activity_factory(user=auth_client.user, to_time=None) - activity2 = activity_factory(user=auth_client.user, task=activity1.task) +def test_activity_set_to_time_none(internal_employee_client, activity_factory): + activity1 = activity_factory(user=internal_employee_client.user, to_time=None) + activity2 = activity_factory( + user=internal_employee_client.user, task=activity1.task + ) data = { "data": { @@ -249,5 +311,5 @@ def test_activity_set_to_time_none(auth_client, activity_factory): url = reverse("activity-detail", args=[activity2.id]) - res = auth_client.patch(url, data) + res = internal_employee_client.patch(url, data) assert res.status_code == status.HTTP_400_BAD_REQUEST diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index 4779227da..186938fe7 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -1,3 +1,4 @@ +import pytest from django.urls import reverse from rest_framework import status @@ -17,17 +18,35 @@ def test_attendance_list(auth_client): assert json["data"][0]["id"] == str(attendance.id) -def test_attendance_detail(auth_client): - attendance = AttendanceFactory.create(user=auth_client.user) +def test_attendance_detail(internal_employee_client): + attendance = AttendanceFactory.create(user=internal_employee_client.user) url = reverse("attendance-detail", args=[attendance.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK -def test_attendance_create(auth_client): +@pytest.mark.parametrize( + "is_external, task_assignee__is_resource, expected", + [ + (False, False, status.HTTP_201_CREATED), + (True, True, status.HTTP_201_CREATED), + (True, False, status.HTTP_403_FORBIDDEN), + ], +) +def test_attendance_create( + auth_client, employment, is_external, task_assignee, expected +): """Should create a new attendance and automatically set the user.""" user = auth_client.user + employment.user = user + task_assignee.user = user + task_assignee.save() + + if is_external: + employment.is_external = True + employment.save() + data = { "data": { "type": "attendances", @@ -43,29 +62,29 @@ def test_attendance_create(auth_client): url = reverse("attendance-list") response = auth_client.post(url, data) - assert response.status_code == status.HTTP_201_CREATED + assert response.status_code == expected - json = response.json() - assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) + if response.status_code == status.HTTP_201_CREATED: + json = response.json() + assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) -def test_attendance_update(auth_client): - attendance = AttendanceFactory.create(user=auth_client.user) +def test_attendance_update(internal_employee_client): + user = internal_employee_client.user + attendance = AttendanceFactory.create(user=user) data = { "data": { "type": "attendances", "id": attendance.id, "attributes": {"to-time": "15:00:00"}, - "relationships": { - "user": {"data": {"id": auth_client.user.id, "type": "users"}} - }, + "relationships": {"user": {"data": {"id": user.id, "type": "users"}}}, } } url = reverse("attendance-detail", args=[attendance.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -74,10 +93,10 @@ def test_attendance_update(auth_client): ) -def test_attendance_delete(auth_client): - attendance = AttendanceFactory.create(user=auth_client.user) +def test_attendance_delete(internal_employee_client): + attendance = AttendanceFactory.create(user=internal_employee_client.user) url = reverse("attendance-detail", args=[attendance.id]) - response = auth_client.delete(url) + response = internal_employee_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 1c85edb0b..4ea726999 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -8,17 +8,20 @@ from django.utils.duration import duration_string from rest_framework import status +from timed.employment.factories import EmploymentFactory, UserFactory +from timed.projects.factories import TaskAssigneeFactory + def test_report_list( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report_factory.create(user=user) report = report_factory.create(user=user, duration=timedelta(hours=1)) url = reverse("report-list") - response = auth_client.get( + response = internal_employee_client.get( url, data={ "date": report.date, @@ -39,13 +42,13 @@ def test_report_list( def test_report_intersection_full( - auth_client, + internal_employee_client, report_factory, ): report = report_factory.create() url = reverse("report-intersection") - response = auth_client.get( + response = internal_employee_client.get( url, data={ "ordering": "task__name", @@ -95,10 +98,10 @@ def test_report_intersection_full( def test_report_intersection_partial( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(review=True, not_billable=True, comment="test") report_factory.create(verified_by=user, comment="test") # Billed is not set on create because the factory doesnt seem to work with that @@ -106,7 +109,7 @@ def test_report_intersection_partial( report.save() url = reverse("report-intersection") - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -133,7 +136,7 @@ def test_report_intersection_partial( def test_report_list_filter_id( - auth_client, + internal_employee_client, report_factory, ): report_1 = report_factory.create(date="2017-01-01") @@ -142,7 +145,7 @@ def test_report_list_filter_id( url = reverse("report-list") - response = auth_client.get( + response = internal_employee_client.get( url, data={"id": "{0},{1}".format(report_1.id, report_2.id), "ordering": "id"} ) assert response.status_code == status.HTTP_200_OK @@ -153,7 +156,7 @@ def test_report_list_filter_id( def test_report_list_filter_id_empty( - auth_client, + internal_employee_client, report_factory, ): """Test that empty id filter is ignored.""" @@ -161,23 +164,23 @@ def test_report_list_filter_id_empty( url = reverse("report-list") - response = auth_client.get(url, data={"id": ""}) + response = internal_employee_client.get(url, data={"id": ""}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 def test_report_list_filter_reviewer( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) report.task.project.reviewers.add(user) url = reverse("report-list") - response = auth_client.get(url, data={"reviewer": user.id}) + response = internal_employee_client.get(url, data={"reviewer": user.id}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 @@ -185,16 +188,16 @@ def test_report_list_filter_reviewer( def test_report_list_filter_verifier( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(verified_by=user) report_factory.create() url = reverse("report-list") - response = auth_client.get(url, data={"verifier": user.id}) + response = internal_employee_client.get(url, data={"verifier": user.id}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 @@ -202,16 +205,16 @@ def test_report_list_filter_verifier( def test_report_list_filter_editable_owner( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) report_factory.create() url = reverse("report-list") - response = auth_client.get(url, data={"editable": 1}) + response = internal_employee_client.get(url, data={"editable": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 @@ -219,16 +222,16 @@ def test_report_list_filter_editable_owner( def test_report_list_filter_not_editable_owner( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report_factory.create(user=user) report = report_factory.create() url = reverse("report-list") - response = auth_client.get(url, data={"editable": 0}) + response = internal_employee_client.get(url, data={"editable": 0}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 @@ -236,9 +239,9 @@ def test_report_list_filter_not_editable_owner( def test_report_list_filter_editable_reviewer( - auth_client, report_factory, user_factory + internal_employee_client, report_factory, user_factory ): - user = auth_client.user + user = internal_employee_client.user # not editable report report_factory.create() @@ -258,13 +261,14 @@ def test_report_list_filter_editable_reviewer( url = reverse("report-list") - response = auth_client.get(url, data={"editable": 1}) + response = internal_employee_client.get(url, data={"editable": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 3 def test_report_list_filter_editable_superuser(superadmin_client, report_factory): + EmploymentFactory.create(user=superadmin_client.user) report = report_factory.create() url = reverse("report-list") @@ -277,6 +281,7 @@ def test_report_list_filter_editable_superuser(superadmin_client, report_factory def test_report_list_filter_not_editable_superuser(superadmin_client, report_factory): + EmploymentFactory.create(user=superadmin_client.user) report_factory.create() url = reverse("report-list") @@ -288,11 +293,11 @@ def test_report_list_filter_not_editable_superuser(superadmin_client, report_fac def test_report_list_filter_editable_supervisor( - auth_client, + internal_employee_client, report_factory, user_factory, ): - user = auth_client.user + user = internal_employee_client.user # not editable report report_factory.create() @@ -310,14 +315,14 @@ def test_report_list_filter_editable_supervisor( url = reverse("report-list") - response = auth_client.get(url, data={"editable": 1}) + response = internal_employee_client.get(url, data={"editable": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 3 def test_report_list_filter_billed( - auth_client, + internal_employee_client, report, ): # Billed is not set on create because the factory doesnt seem to work with that @@ -326,7 +331,7 @@ def test_report_list_filter_billed( url = reverse("report-list") - response = auth_client.get(url, data={"billed": 1}) + response = internal_employee_client.get(url, data={"billed": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 @@ -334,34 +339,55 @@ def test_report_list_filter_billed( def test_report_export_missing_type( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user url = reverse("report-export") - response = auth_client.get(url, data={"user": user.id}) + response = internal_employee_client.get(url, data={"user": user.id}) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_report_detail( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) url = reverse("report-detail", args=[report.id]) - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK -def test_report_create(auth_client, report_factory, task_factory): +@pytest.mark.parametrize( + "task_assignee__is_reviewer, task_assignee__is_manager, task_assignee__is_resource, is_external, expected", + [ + (True, False, False, True, status.HTTP_400_BAD_REQUEST), + (False, True, False, True, status.HTTP_400_BAD_REQUEST), + (False, False, True, True, status.HTTP_201_CREATED), + (True, False, False, False, status.HTTP_201_CREATED), + (False, True, False, False, status.HTTP_201_CREATED), + (False, False, True, False, status.HTTP_201_CREATED), + ], +) +def test_report_create( + auth_client, report_factory, task_factory, task_assignee, is_external, expected +): """Should create a new report and automatically set the user.""" user = auth_client.user task = task_factory.create() + task_assignee.user = user + task_assignee.task = task + task_assignee.save() + + if is_external: + EmploymentFactory.create(user=user, is_external=True) + else: + EmploymentFactory.create(user=user, is_external=False) data = { "data": { @@ -382,19 +408,20 @@ def test_report_create(auth_client, report_factory, task_factory): url = reverse("report-list") response = auth_client.post(url, data) - assert response.status_code == status.HTTP_201_CREATED + assert response.status_code == expected - json = response.json() - assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) + if response.status_code == status.HTTP_201_CREATED: + json = response.json() + assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) - assert json["data"]["relationships"]["task"]["data"]["id"] == str(task.id) + assert json["data"]["relationships"]["task"]["data"]["id"] == str(task.id) def test_report_create_billed( - auth_client, report_factory, project_factory, task_factory + internal_employee_client, report_factory, project_factory, task_factory ): """Should create a new report and automatically set the user.""" - user = auth_client.user + user = internal_employee_client.user project = project_factory.create(billed=True) task = task_factory.create(project=project) @@ -416,7 +443,7 @@ def test_report_create_billed( url = reverse("report-list") - response = auth_client.post(url, data) + response = internal_employee_client.post(url, data) assert response.status_code == status.HTTP_201_CREATED json = response.json() @@ -428,12 +455,12 @@ def test_report_create_billed( def test_report_update_bulk( - auth_client, + internal_employee_client, report_factory, task_factory, ): task = task_factory.create() - report = report_factory.create(user=auth_client.user) + report = report_factory.create(user=internal_employee_client.user) url = reverse("report-bulk") @@ -445,7 +472,7 @@ def test_report_update_bulk( } } - response = auth_client.post(url + "?editable=1", data) + response = internal_employee_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() @@ -453,10 +480,10 @@ def test_report_update_bulk( def test_report_update_bulk_verify_non_reviewer( - auth_client, + internal_employee_client, report_factory, ): - report_factory.create(user=auth_client.user) + report_factory.create(user=internal_employee_client.user) url = reverse("report-bulk") @@ -464,12 +491,13 @@ def test_report_update_bulk_verify_non_reviewer( "data": {"type": "report-bulks", "id": None, "attributes": {"verified": True}} } - response = auth_client.post(url + "?editable=1", data) + response = internal_employee_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_report_update_bulk_verify_superuser(superadmin_client, report_factory): user = superadmin_client.user + EmploymentFactory.create(user=user) report = report_factory.create(user=user) url = reverse("report-bulk") @@ -486,10 +514,10 @@ def test_report_update_bulk_verify_superuser(superadmin_client, report_factory): def test_report_update_bulk_verify_reviewer( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) report.task.project.reviewers.add(user) @@ -503,7 +531,9 @@ def test_report_update_bulk_verify_reviewer( } } - response = auth_client.post(url + "?editable=1&reviewer={0}".format(user.id), data) + response = internal_employee_client.post( + url + "?editable=1&reviewer={0}".format(user.id), data + ) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() @@ -513,6 +543,7 @@ def test_report_update_bulk_verify_reviewer( def test_report_update_bulk_reset_verify(superadmin_client, report_factory): user = superadmin_client.user + EmploymentFactory.create(user=user) report = report_factory.create(verified_by=user) url = reverse("report-bulk") @@ -529,7 +560,7 @@ def test_report_update_bulk_reset_verify(superadmin_client, report_factory): def test_report_update_bulk_not_editable( - auth_client, + internal_employee_client, report_factory, ): url = reverse("report-bulk") @@ -542,16 +573,16 @@ def test_report_update_bulk_not_editable( } } - response = auth_client.post(url, data) + response = internal_employee_client.post(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_report_update_verified_as_non_staff_but_owner( - auth_client, + internal_employee_client, report_factory, ): """Test that an owner (not staff) may not change a verified report.""" - user = auth_client.user + user = internal_employee_client.user report = report_factory.create( user=user, verified_by=user, duration=timedelta(hours=2) ) @@ -566,13 +597,13 @@ def test_report_update_verified_as_non_staff_but_owner( } } - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN -def test_report_update_owner(auth_client, report_factory, task_factory): +def test_report_update_owner(internal_employee_client, report_factory, task_factory): """Should update an existing report.""" - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) task = task_factory.create() @@ -591,7 +622,7 @@ def test_report_update_owner(auth_client, report_factory, task_factory): url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -608,10 +639,10 @@ def test_report_update_owner(auth_client, report_factory, task_factory): def test_report_update_date_reviewer( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create() report.task.project.reviewers.add(user) @@ -625,15 +656,15 @@ def test_report_update_date_reviewer( url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) - assert response.status_code == status.HTTP_400_BAD_REQUEST + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_403_FORBIDDEN def test_report_update_duration_reviewer( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(duration=timedelta(hours=2)) report.task.project.reviewers.add(user) @@ -647,12 +678,12 @@ def test_report_update_duration_reviewer( url = reverse("report-detail", args=[report.id]) - res = auth_client.patch(url, data) - assert res.status_code == status.HTTP_400_BAD_REQUEST + res = internal_employee_client.patch(url, data) + assert res.status_code == status.HTTP_403_FORBIDDEN def test_report_update_by_user( - auth_client, + internal_employee_client, report_factory, ): """Updating of report belonging to different user is not allowed.""" @@ -666,15 +697,15 @@ def test_report_update_by_user( } url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN def test_report_update_verified_and_review_reviewer( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(duration=timedelta(hours=2)) report.task.project.reviewers.add(user) @@ -691,16 +722,16 @@ def test_report_update_verified_and_review_reviewer( url = reverse("report-detail", args=[report.id]) - res = auth_client.patch(url, data) - assert res.status_code == status.HTTP_400_BAD_REQUEST + res = internal_employee_client.patch(url, data) + assert res.status_code == status.HTTP_403_FORBIDDEN def test_report_set_verified_by_user( - auth_client, + internal_employee_client, report_factory, ): """Test that normal user may not verify report.""" - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) data = { "data": { @@ -713,15 +744,15 @@ def test_report_set_verified_by_user( } url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_report_update_reviewer( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) report.task.project.reviewers.add(user) @@ -738,15 +769,15 @@ def test_report_update_reviewer( url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK def test_report_update_supervisor( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) report.user.supervisors.add(user) @@ -760,12 +791,13 @@ def test_report_update_supervisor( url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK def test_report_verify_other_user(superadmin_client, report_factory, user_factory): """Verify that superuser may not verify to other user.""" + EmploymentFactory.create(user=superadmin_client.user) user = user_factory.create() report = report_factory.create() @@ -785,11 +817,11 @@ def test_report_verify_other_user(superadmin_client, report_factory, user_factor def test_report_reset_verified_by_reviewer( - auth_client, + internal_employee_client, report_factory, ): - """Test that reviewer may change verified report.""" - user = auth_client.user + """Test that reviewer may not change verified report.""" + user = internal_employee_client.user report = report_factory.create(user=user, verified_by=user) report.task.project.reviewers.add(user) @@ -803,19 +835,19 @@ def test_report_reset_verified_by_reviewer( } url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) - assert response.status_code == status.HTTP_200_OK + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_403_FORBIDDEN report.refresh_from_db() report.verified_by = None def test_report_reset_verified_and_billed_by_reviewer( - auth_client, + internal_employee_client, report_factory, ): """Test that reviewer may not change verified and billed report.""" - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user, verified_by=user) report.task.project.reviewers.add(user) # Billed is not set on create because the factory doesnt seem to work with that @@ -832,20 +864,45 @@ def test_report_reset_verified_and_billed_by_reviewer( } url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN +@pytest.mark.parametrize( + "task_assignee__is_reviewer, task_assignee__is_manager, task_assignee__is_resource, is_external, verified, expected", + [ + (True, False, False, False, True, status.HTTP_403_FORBIDDEN), + (False, False, True, False, False, status.HTTP_204_NO_CONTENT), + (False, False, True, False, True, status.HTTP_403_FORBIDDEN), + (False, False, True, False, False, status.HTTP_204_NO_CONTENT), + (True, False, False, True, False, status.HTTP_204_NO_CONTENT), + (False, True, False, True, False, status.HTTP_403_FORBIDDEN), + (False, False, True, True, False, status.HTTP_204_NO_CONTENT), + (True, False, False, True, True, status.HTTP_403_FORBIDDEN), + (False, True, False, True, True, status.HTTP_403_FORBIDDEN), + (False, False, True, True, True, status.HTTP_403_FORBIDDEN), + ], +) def test_report_delete( - auth_client, - report_factory, + auth_client, report_factory, task_assignee, is_external, verified, expected ): user = auth_client.user - report = report_factory.create(user=user) + task_assignee.user = user + task_assignee.save() + report = report_factory.create(user=user, task=task_assignee.task) + + if verified: + report.verified_by = UserFactory.create() + report.save() + + if is_external: + EmploymentFactory.create(user=user, is_external=True) + else: + EmploymentFactory.create(user=user, is_external=False) url = reverse("report-detail", args=[report.id]) response = auth_client.delete(url) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == expected def test_report_round_duration(db, report_factory): @@ -869,6 +926,7 @@ def test_report_round_duration(db, report_factory): def test_report_list_no_result(admin_client): + EmploymentFactory.create(user=admin_client.user) url = reverse("report-list") res = admin_client.get(url) @@ -879,6 +937,7 @@ def test_report_list_no_result(admin_client): def test_report_delete_superuser(superadmin_client, report_factory): """Test that superuser may not delete reports of other users.""" + EmploymentFactory.create(user=superadmin_client.user) report = report_factory.create() url = reverse("report-detail", args=[report.id]) @@ -887,7 +946,11 @@ def test_report_delete_superuser(superadmin_client, report_factory): def test_report_list_filter_cost_center( - auth_client, report_factory, cost_center_factory, project_factory, task_factory + internal_employee_client, + report_factory, + cost_center_factory, + project_factory, + task_factory, ): cost_center = cost_center_factory.create() # 1st valid case: report with task of given cost center @@ -905,7 +968,7 @@ def test_report_list_filter_cost_center( url = reverse("report-list") - res = auth_client.get(url, data={"cost_center": cost_center.id}) + res = internal_employee_client.get(url, data={"cost_center": cost_center.id}) assert res.status_code == status.HTTP_200_OK json = res.json() assert len(json["data"]) == 2 @@ -931,7 +994,7 @@ def test_report_list_filter_cost_center( "project_bt,expected_bt_name", [(True, "Some billing type"), (False, "")] ) def test_report_export( - auth_client, + internal_employee_client, django_assert_num_queries, report, task, @@ -963,8 +1026,8 @@ def test_report_export( url = reverse("report-export") - with django_assert_num_queries(1): - response = auth_client.get(url, data={"file_type": file_type}) + with django_assert_num_queries(2): + response = internal_employee_client.get(url, data={"file_type": file_type}) assert response.status_code == status.HTTP_200_OK @@ -986,7 +1049,7 @@ def test_report_export( ], ) def test_report_export_max_count( - auth_client, + internal_employee_client, django_assert_num_queries, report_factory, task, @@ -1000,15 +1063,21 @@ def test_report_export_max_count( url = reverse("report-export") - response = auth_client.get(url, data={"file_type": "csv"}) + response = internal_employee_client.get(url, data={"file_type": "csv"}) assert response.status_code == expected_status def test_report_update_bulk_verify_reviewer_multiple_notify( - auth_client, task, task_factory, project, report_factory, user_factory, mailoutbox + internal_employee_client, + task, + task_factory, + project, + report_factory, + user_factory, + mailoutbox, ): - reviewer = auth_client.user + reviewer = internal_employee_client.user project.reviewers.add(reviewer) user1, user2, user3 = user_factory.create_batch(3) @@ -1035,7 +1104,7 @@ def test_report_update_bulk_verify_reviewer_multiple_notify( f"&reviewer={reviewer.id}" "&id=" + ",".join(str(r.id) for r in [report1_1, report1_2, report2, report3]) ) - response = auth_client.post(url + query_params, data) + response = internal_employee_client.post(url + query_params, data) assert response.status_code == status.HTTP_204_NO_CONTENT for report in [report1_1, report1_2, report2, report3]: @@ -1064,7 +1133,7 @@ def test_report_update_bulk_verify_reviewer_multiple_notify( ], ) def test_report_update_reviewer_notify( - auth_client, + internal_employee_client, user_factory, report_factory, task_factory, @@ -1075,7 +1144,7 @@ def test_report_update_reviewer_notify( verified, expected, ): - reviewer = auth_client.user + reviewer = internal_employee_client.user user = user_factory() if own_report: @@ -1084,6 +1153,9 @@ def test_report_update_reviewer_notify( report = report_factory(user=user, review=True) report.task.project.reviewers.set([reviewer, user]) new_task = task_factory(project=report.task.project) + task = report.task + TaskAssigneeFactory.create(user=user, is_resource=True, task=task) + TaskAssigneeFactory.create(user=reviewer, is_reviewer=True, task=task) data = { "data": { @@ -1107,7 +1179,7 @@ def test_report_update_reviewer_notify( url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK mail_count = 1 if not own_report and expected else 0 @@ -1120,7 +1192,7 @@ def test_report_update_reviewer_notify( def test_report_notify_rendering( - auth_client, + internal_employee_client, user_factory, project, report_factory, @@ -1128,7 +1200,7 @@ def test_report_notify_rendering( mailoutbox, snapshot, ): - reviewer = auth_client.user + reviewer = internal_employee_client.user user = user_factory() project.reviewers.add(reviewer) task1, task2, task3 = task_factory.create_batch(3, project=project) @@ -1162,7 +1234,7 @@ def test_report_notify_rendering( f"&reviewer={reviewer.id}" "&id=" + ",".join(str(r.id) for r in [report1, report2, report3, report4]) ) - response = auth_client.post(url + query_params, data) + response = internal_employee_client.post(url + query_params, data) assert response.status_code == status.HTTP_204_NO_CONTENT assert len(mailoutbox) == 1 @@ -1175,6 +1247,7 @@ def test_report_notify_rendering( def test_report_update_bulk_review_and_verified( superadmin_client, project, task, report, user_factory, needs_review ): + EmploymentFactory.create(user=superadmin_client.user) data = { "data": {"type": "report-bulks", "id": None, "attributes": {"verified": True}} } @@ -1190,24 +1263,24 @@ def test_report_update_bulk_review_and_verified( def test_report_update_bulk_bill_non_reviewer( - auth_client, + internal_employee_client, report_factory, ): - report_factory.create(user=auth_client.user) + report_factory.create(user=internal_employee_client.user) url = reverse("report-bulk") data = {"data": {"type": "report-bulks", "id": None, "attributes": {"billed": 1}}} - response = auth_client.post(url + "?editable=1", data) + response = internal_employee_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_report_update_bulk_bill_reviewer( - auth_client, + internal_employee_client, report_factory, ): - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) report.task.project.reviewers.add(user) @@ -1221,7 +1294,9 @@ def test_report_update_bulk_bill_reviewer( } } - response = auth_client.post(url + "?editable=1&reviewer={0}".format(user.id), data) + response = internal_employee_client.post( + url + "?editable=1&reviewer={0}".format(user.id), data + ) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() @@ -1229,7 +1304,7 @@ def test_report_update_bulk_bill_reviewer( def test_report_update_billed_user( - auth_client, + internal_employee_client, report_factory, ): report = report_factory.create() @@ -1244,16 +1319,16 @@ def test_report_update_billed_user( url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN def test_report_set_billed_by_user( - auth_client, + internal_employee_client, report_factory, ): """Test that normal user may not bill report.""" - user = auth_client.user + user = internal_employee_client.user report = report_factory.create(user=user) data = { "data": { @@ -1264,12 +1339,12 @@ def test_report_set_billed_by_user( } url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_report_update_billed(auth_client, report_factory, task): - user = auth_client.user +def test_report_update_billed(internal_employee_client, report_factory, task): + user = internal_employee_client.user report = report_factory.create(user=user) report.task.project.billed = True report.task.project.save() @@ -1283,7 +1358,7 @@ def test_report_update_billed(auth_client, report_factory, task): } url = reverse("report-detail", args=[report.id]) - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK report.refresh_from_db() @@ -1300,15 +1375,15 @@ def test_report_update_billed(auth_client, report_factory, task): } } - response = auth_client.patch(url, data) + response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK report.refresh_from_db() assert not report.billed -def test_report_update_bulk_billed(auth_client, report_factory, task): - user = auth_client.user +def test_report_update_bulk_billed(internal_employee_client, report_factory, task): + user = internal_employee_client.user report = report_factory.create(user=user) report.task.project.reviewers.add(user) task.project.billed = True @@ -1327,8 +1402,37 @@ def test_report_update_bulk_billed(auth_client, report_factory, task): } } - response = auth_client.post(url + "?editable=1&reviewer={0}".format(user.id), data) + response = internal_employee_client.post( + url + "?editable=1&reviewer={0}".format(user.id), data + ) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() assert report.billed + + +def test_report_list_external_employee(external_employee_client, report_factory): + user = external_employee_client.user + report = report_factory.create(user=user, duration=timedelta(hours=1)) + TaskAssigneeFactory.create(user=user, task=report.task, is_resource=True) + report_factory.create_batch(4) + url = reverse("report-list") + + response = external_employee_client.get( + url, + data={ + "date": report.date, + "user": user.id, + "task": report.task_id, + "project": report.task.project_id, + "customer": report.task.project.customer_id, + "include": ("user,task,task.project,task.project.customer,verified_by"), + }, + ) + + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(report.id) + assert json["meta"]["total-time"] == "01:00:00" From 159e75033ed4c477d56f2a2817dee82b3066d2a9 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 27 Jul 2021 15:13:25 +0200 Subject: [PATCH 838/980] feat: rewrite permissions and visibility rewrite permissions and visibility to include assignees --- timed/employment/tests/test_user.py | 17 +- timed/employment/views.py | 34 +++- timed/permissions.py | 180 +++++++++++++++++- timed/projects/models.py | 36 ---- timed/projects/serializers.py | 49 ++++- timed/projects/tests/test_billing_type.py | 4 +- timed/projects/tests/test_cost_center.py | 4 +- timed/projects/tests/test_task.py | 40 ++-- timed/projects/views.py | 93 ++++++--- .../reports/tests/test_customer_statistic.py | 8 +- timed/reports/tests/test_month_statistic.py | 4 +- timed/reports/tests/test_project_statistic.py | 6 +- timed/reports/tests/test_task_statistic.py | 6 +- timed/reports/tests/test_user_statistic.py | 8 +- timed/reports/tests/test_year_statistic.py | 4 +- timed/reports/views.py | 31 +++ timed/tracking/serializers.py | 49 ++++- timed/tracking/tests/test_absence.py | 20 +- timed/tracking/tests/test_activity.py | 18 +- timed/tracking/tests/test_attendance.py | 6 +- timed/tracking/tests/test_report.py | 54 +++++- timed/tracking/views.py | 73 +++++-- 22 files changed, 588 insertions(+), 156 deletions(-) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index a65704adf..eca57eb44 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -40,6 +40,19 @@ def test_user_list(db, internal_employee_client, django_assert_num_queries): assert len(json["data"]) == 3 +def test_user_list_external_employee(external_employee_client): + UserFactory.create_batch(2) + + url = reverse("user-list") + + response = external_employee_client.get(url) + + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 + + def test_user_detail(internal_employee_client): user = internal_employee_client.user @@ -148,7 +161,9 @@ def test_user_supervisor_filter(internal_employee_client): internal_employee_client.user.supervisees.add(*supervisees) internal_employee_client.user.save() - res = internal_employee_client.get(reverse("user-list"), {"supervisor": internal_employee_client.user.id}) + res = internal_employee_client.get( + reverse("user-list"), {"supervisor": internal_employee_client.user.id} + ) assert len(res.json()["data"]) == 5 diff --git a/timed/employment/views.py b/timed/employment/views.py index c45db479d..252d5e4de 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -24,6 +24,7 @@ IsSupervisor, IsUpdateOnly, ) +from timed.projects.models import Task from timed.tracking.models import Absence, Report @@ -51,10 +52,34 @@ class UserViewSet(ModelViewSet): search_fields = ("username", "first_name", "last_name") def get_queryset(self): - return get_user_model().objects.prefetch_related( + user = self.request.user + current_employment = models.Employment.objects.get_at( + user=user, date=datetime.date.today() + ) + queryset = get_user_model().objects.prefetch_related( "employments", "supervisees", "supervisors" ) + if current_employment.is_external: + assigned_tasks = Task.objects.filter( + Q(task_assignees__user=user, task_assignees__is_reviewer=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + ) + ) + visible_reports = Report.objects.all().filter( + Q(task__in=assigned_tasks) | Q(user=user) + ) + + return queryset.filter(Q(reports__in=visible_reports) | Q(id=user.id)) + + return queryset + @action(methods=["get"], detail=False) def me(self, request, pk=None): User = get_user_model() @@ -250,7 +275,12 @@ def get_queryset(self): queryset = queryset.annotate(user=Value(user.id, IntegerField())) queryset = queryset.annotate( pk=Concat( - "user", Value("_"), "id", Value("_"), "date", output_field=CharField() + "user", + Value("_"), + "id", + Value("_"), + "date", + output_field=CharField(), ) ) diff --git a/timed/permissions.py b/timed/permissions.py index 26cd01740..2c5cab230 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -1,6 +1,13 @@ +# from django.utils import timezone +from datetime import date + +from django.db.models import Q +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated +from timed.employment import models as employment_models from timed.projects import models as projects_models +from timed.tracking import models as tracking_models class IsUnverified(BasePermission): @@ -85,6 +92,12 @@ def has_object_permission(self, request, view, obj): class IsSupervisor(IsAuthenticated): """Allows access to object only to supervisors.""" + def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + + return request.user.supervisees.exists() + def has_object_permission(self, request, view, obj): if not super().has_object_permission(request, view, obj): # pragma: no cover return False @@ -99,9 +112,6 @@ def has_permission(self, request, view): if not super().has_permission(request, view): # pragma: no cover return False - if request.method not in SAFE_METHODS: - return request.user.reviews.exists() - return True def has_object_permission(self, request, view, obj): @@ -110,10 +120,25 @@ def has_object_permission(self, request, view, obj): user = request.user - if isinstance(obj, projects_models.Task): - return obj.project.reviewers.filter(id=user.id).exists() - - return obj.task.project.reviewers.filter(id=user.id).exists() + if isinstance(obj, tracking_models.Report): + task = obj.task + else: # pragma: no cover + raise RuntimeError("IsReviewer permission called on unsupported model") + return ( + projects_models.Task.objects.filter(pk=task.pk) + .filter( + Q(task_assignees__user=user, task_assignees__is_reviewer=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + ) + ) + .exists() + ) class IsSuperUser(IsAuthenticated): @@ -141,3 +166,144 @@ class IsNotBilledAndVerfied(BasePermission): def has_object_permission(self, request, view, obj): return not obj.billed or obj.verified_by_id is None + + +class IsInternal(IsAuthenticated): + """Allows access only to internal employees.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + + try: + employment = employment_models.Employment.objects.get_at( + user=request.user, date=date.today() + ) + return not employment.is_external + except employment_models.Employment.DoesNotExist: + raise PermissionDenied("User has no employment") + + def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + + employment = employment_models.Employment.objects.get_at( + user=request.user, date=date.today() + ) + if employment: + return not employment.is_external + return False # pragma: no cover + + +class IsExternal(IsAuthenticated): + """Allows access only to external employees.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + + try: + employment = employment_models.Employment.objects.get_at( + user=request.user, date=date.today() + ) + return employment.is_external + except employment_models.Employment.DoesNotExist: # pragma: no cover + raise PermissionDenied("User has no employment") + + def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + + employment = employment_models.Employment.objects.get_at( + user=request.user, date=date.today() + ) + if employment: + return employment.is_external + return False # pragma: no cover + + +class IsManager(IsAuthenticated): + """Allows access only to assignees with manager role.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + + if ( + request.user.customer_assignees.filter(is_manager=True).exists() + or request.user.project_assignees.filter(is_manager=True).exists() + or request.user.task_assignees.filter(is_manager=True).exists() + ): + return True + return False + + def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + + user = request.user + + if isinstance(obj, projects_models.Task): + return ( + projects_models.Task.objects.filter(pk=obj.pk) + .filter( + Q(task_assignees__user=user, task_assignees__is_manager=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_manager=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_manager=True, + ) + ) + .exists() + ) + else: # pragma: no cover + raise RuntimeError("IsManager permission called on unsupported model") + + +class IsResource(IsAuthenticated): + """Allows access only to assignees with resource role.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + + if ( + request.user.customer_assignees.filter(is_resource=True).exists() + or request.user.project_assignees.filter(is_resource=True).exists() + or request.user.task_assignees.filter(is_resource=True).exists() + ): + return True + return False + + def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + + user = request.user + + if isinstance(obj, tracking_models.Activity) or isinstance( + obj, tracking_models.Report + ): + task = obj.task + task = obj.task + else: # pragma: no cover + raise RuntimeError("IsResource permission called on unsupported model") + + return ( + projects_models.Task.objects.filter(pk=task.pk) + .filter( + Q(task_assignees__user=user, task_assignees__is_resource=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_resource=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_resource=True, + ) + ) + .exists() + ) diff --git a/timed/projects/models.py b/timed/projects/models.py index 42dc7318a..04f0834ea 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -2,8 +2,6 @@ from django.conf import settings from django.db import models -from django.db.models.signals import post_delete, post_save -from django.dispatch import receiver from djmoney.models.fields import MoneyField @@ -245,37 +243,3 @@ class TaskAssignee(models.Model): is_resource = models.BooleanField(default=False) is_reviewer = models.BooleanField(default=False) is_manager = models.BooleanField(default=False) - - -@receiver(post_save, sender=Project.assignees.through) -def create_or_update_project_assignee(sender, instance, created, **kwargs): - """Create or update current project assignee and corresponding reviewer. - - If the created project assignee should be a reviewer, create a corresponding reviewer object. - If a project assignee's is_reviewer attribute is updated, either create a new reviewer object or delete the corresponding one. - """ - if instance.is_reviewer: # pragma: no cover - if not Project.reviewers.through.objects.filter( - user=instance.user, project=instance.project - ): - Project.reviewers.through.objects.create( - user=instance.user, project=instance.project - ) - elif not created and not instance.is_reviewer: # pragma: no cover - Project.reviewers.through.objects.get( - user=instance.user, project=instance.project - ).delete() - - -@receiver(post_delete, sender=Project.assignees.through) -def delete_project_assignee(sender, instance, **kwargs): - """Delete project assignee. - - If the project assignee is also a reviewer, delete the corresponding reviewer object. - """ - if Project.reviewers.through.objects.filter( - user=instance.user, project=instance.project - ): # pragma: no cover - Project.reviewers.through.objects.get( - user=instance.user, project=instance.project - ).delete() diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 5033a9547..b460093e6 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -1,7 +1,7 @@ """Serializers for the projects app.""" from datetime import timedelta -from django.db.models import Sum +from django.db.models import Q, Sum from django.utils.duration import duration_string from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.serializers import ModelSerializer @@ -115,6 +115,53 @@ def get_root_meta(self, resource, many): return {} + def validate(self, data): + """Validate the role of the user. + + Check if the user is a manager on the corresponding + project or customer when he wants to create a new task. + + Check if the user is a manager on the task or + the corresponding project or customer when he wants to update the task. + """ + request = self.context["request"] + user = request.user + if self.instance: + if ( + models.Task.objects.filter(id=self.instance.id) + .filter( + Q( + task_assignees__user=user, + task_assignees__is_manager=True, + ) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_manager=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_manager=True, + ) + ) + .exists() + ): + return data + elif ( + models.Project.objects.filter(pk=data["project"].id) + .filter( + Q( + project_assignees__user=user, + project_assignees__is_manager=True, + ) + | Q( + customer__customer_assignees__user=user, + customer__customer_assignees__is_manager=True, + ) + ) + .exists() + ): + return data + class Meta: """Meta information for the task serializer.""" diff --git a/timed/projects/tests/test_billing_type.py b/timed/projects/tests/test_billing_type.py index ba3bb43c6..001ff0775 100644 --- a/timed/projects/tests/test_billing_type.py +++ b/timed/projects/tests/test_billing_type.py @@ -4,11 +4,11 @@ from timed.projects.factories import BillingTypeFactory -def test_billing_type_list(auth_client): +def test_billing_type_list(internal_employee_client): billing_type = BillingTypeFactory.create() url = reverse("billing-type-list") - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() assert len(json["data"]) == 1 diff --git a/timed/projects/tests/test_cost_center.py b/timed/projects/tests/test_cost_center.py index 26b0e9a01..2510e8893 100644 --- a/timed/projects/tests/test_cost_center.py +++ b/timed/projects/tests/test_cost_center.py @@ -4,11 +4,11 @@ from timed.projects.factories import CostCenterFactory -def test_cost_center_list(auth_client): +def test_cost_center_list(internal_employee_client): cost_center = CostCenterFactory.create() url = reverse("cost-center-list") - res = auth_client.get(url) + res = internal_employee_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() assert len(json["data"]) == 1 diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index dc83c2527..32b2005f9 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -6,7 +6,7 @@ from rest_framework import status from timed.employment.factories import EmploymentFactory -from timed.projects.factories import TaskFactory +from timed.projects.factories import ProjectFactory, TaskFactory def test_task_list_not_archived(internal_employee_client, task_factory): @@ -63,11 +63,11 @@ def test_task_detail(internal_employee_client, task): @pytest.mark.parametrize( - "project_assignee__is_resource, project_assignee__is_manager, project_assignee__is_reviewer, customer_assignee__is_reviewer, expected", + "project_assignee__is_resource, project_assignee__is_manager, project_assignee__is_reviewer, customer_assignee__is_manager, expected", [ - (True, False, False, False, status.HTTP_400_BAD_REQUEST), + (True, False, False, False, status.HTTP_403_FORBIDDEN), (False, True, False, False, status.HTTP_201_CREATED), - (False, False, True, False, status.HTTP_201_CREATED), + (False, False, True, False, status.HTTP_403_FORBIDDEN), (False, False, False, True, status.HTTP_201_CREATED), ], ) @@ -77,7 +77,7 @@ def test_task_create( user = auth_client.user project_assignee.user = user project_assignee.save() - if customer_assignee.is_reviewer: + if customer_assignee.is_manager: customer_assignee.customer = project.customer customer_assignee.user = user customer_assignee.save() @@ -98,23 +98,41 @@ def test_task_create( @pytest.mark.parametrize( - "task_assignee__is_resource, task_assignee__is_manager, task_assignee__is_reviewer, expected", + "task_assignee__is_resource, task_assignee__is_manager, task_assignee__is_reviewer, project_assignee__is_reviewer, project_assignee__is_manager, different_project, expected", [ - (True, False, False, status.HTTP_403_FORBIDDEN), - (False, True, False, status.HTTP_200_OK), - (False, False, True, status.HTTP_200_OK), + (True, False, False, False, False, False, status.HTTP_403_FORBIDDEN), + (False, True, False, False, False, False, status.HTTP_200_OK), + (False, False, True, False, False, False, status.HTTP_403_FORBIDDEN), + (False, False, False, True, False, False, status.HTTP_403_FORBIDDEN), + (False, False, False, False, True, False, status.HTTP_200_OK), + (False, False, False, False, True, True, status.HTTP_403_FORBIDDEN), ], ) -def test_task_update(auth_client, task, task_assignee, expected): +def test_task_update( + auth_client, task, task_assignee, project_assignee, different_project, expected +): user = auth_client.user EmploymentFactory.create(user=user) task_assignee.task = task task_assignee.user = user task_assignee.save() + if different_project: + project = ProjectFactory.create() + project_assignee.project = project + project_assignee.user = user + project_assignee.save() + + data = { + "data": { + "type": "tasks", + "id": task.id, + "attributes": {"name": "Test Task"}, + } + } url = reverse("task-detail", args=[task.id]) - response = auth_client.patch(url) + response = auth_client.patch(url, data) assert response.status_code == expected diff --git a/timed/projects/views.py b/timed/projects/views.py index 255c743ba..ccc604435 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -2,10 +2,18 @@ from datetime import date +from django.db.models import Q from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework_json_api.views import PreloadIncludesMixin -from timed.permissions import IsAuthenticated, IsReadOnly, IsReviewer, IsSuperUser +from timed.employment.models import Employment +from timed.permissions import ( + IsAuthenticated, + IsInternal, + IsManager, + IsReadOnly, + IsSuperUser, +) from timed.projects import filters, models, serializers @@ -24,22 +32,29 @@ def get_queryset(self): :return: The customers :rtype: QuerySet """ - all_user_employments = self.request.user.employments.all() - current_date = date.today() + user = self.request.user + current_employment = Employment.objects.get_at(user=user, date=date.today()) + queryset = models.Customer.objects.prefetch_related("projects") - for employment in all_user_employments: - if not employment.is_external: # pragma: no cover - return models.Customer.objects.prefetch_related("projects") - elif not employment.end_date or ( - employment.start_date <= current_date - and employment.end_date >= current_date - ): - return models.Customer.objects.filter(assignees=self.request.user) + if not current_employment.is_external: # pragma: no cover + return queryset + else: + return queryset.filter( + Q(assignees=user) + | Q(projects__assignees=user) + | Q(projects__tasks__assignees=user) + ) class BillingTypeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.BillingTypeSerializer ordering = "name" + permission_classes = [ + # superuser may edit all billing types + IsSuperUser + # internal employees may read all billing types + | IsAuthenticated & IsInternal & IsReadOnly + ] def get_queryset(self): return models.BillingType.objects.all() @@ -48,6 +63,12 @@ def get_queryset(self): class CostCenterViewSet(ReadOnlyModelViewSet): serializer_class = serializers.CostCenterSerializer ordering = "name" + permission_classes = [ + # superuser may edit all cost centers + IsSuperUser + # internal employees may read all cost centers + | IsAuthenticated & IsInternal & IsReadOnly + ] def get_queryset(self): return models.CostCenter.objects.all() @@ -69,21 +90,22 @@ class ProjectViewSet(PreloadIncludesMixin, ReadOnlyModelViewSet): def get_queryset(self): """Get only assigned projects, if an employee is external.""" - all_user_employments = self.request.user.employments.all() - current_date = date.today() - - for employment in all_user_employments: - if not employment.is_external: # pragma: no cover - queryset = super().get_queryset() - return queryset.select_related( - "customer", "billing_type", "cost_center" - ) - elif not employment.end_date or ( - employment.start_date <= current_date - and employment.end_date >= current_date - ): - queryset = models.Project.objects.filter(assignees=self.request.user) - return queryset.select_related("customer") + user = self.request.user + current_employment = Employment.objects.get_at(user=user, date=date.today()) + queryset = ( + super() + .get_queryset() + .select_related("customer", "billing_type", "cost_center") + ) + + if not current_employment.is_external: # pragma: no cover + return queryset + else: + return queryset.filter( + Q(assignees=user) + | Q(tasks__assignees=user) + | Q(customer__assignees=user) + ) class TaskViewSet(ModelViewSet): @@ -95,8 +117,8 @@ class TaskViewSet(ModelViewSet): permission_classes = [ # superuser may edit all tasks IsSuperUser - # reviewer may edit all tasks - | IsReviewer + # managers may edit all tasks + | IsManager # all authenticated users may read all tasks | IsAuthenticated & IsReadOnly ] @@ -111,3 +133,18 @@ def filter_queryset(self, queryset): self.ordering = None return super().filter_queryset(queryset) + + def get_queryset(self): + """Get only assigned tasks, if an employee is external.""" + user = self.request.user + current_employment = Employment.objects.get_at(user=user, date=date.today()) + queryset = super().get_queryset().select_related("project", "cost_center") + + if not current_employment.is_external: # pragma: no cover + return queryset + else: + return queryset.filter( + Q(assignees=user) + | Q(project__assignees=user) + | Q(project__customer__assignees=user) + ) diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 3be0b14cf..e8622b8b6 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -5,14 +5,14 @@ from timed.tracking.factories import ReportFactory -def test_customer_statistic_list(auth_client, django_assert_num_queries): +def test_customer_statistic_list(internal_employee_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("customer-statistic-list") - with django_assert_num_queries(5): - result = auth_client.get( + with django_assert_num_queries(6): + result = internal_employee_client.get( url, data={"ordering": "duration", "include": "customer"} ) assert result.status_code == 200 @@ -56,7 +56,7 @@ def test_customer_statistic_detail(internal_employee_client, django_assert_num_q report = ReportFactory.create(duration=timedelta(hours=1)) url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) - with django_assert_num_queries(4): + with django_assert_num_queries(6): result = internal_employee_client.get( url, data={"ordering": "duration", "include": "customer"} ) diff --git a/timed/reports/tests/test_month_statistic.py b/timed/reports/tests/test_month_statistic.py index 9643af6cd..8c17e6124 100644 --- a/timed/reports/tests/test_month_statistic.py +++ b/timed/reports/tests/test_month_statistic.py @@ -5,13 +5,13 @@ from timed.tracking.factories import ReportFactory -def test_month_statistic_list(auth_client): +def test_month_statistic_list(internal_employee_client): ReportFactory.create(duration=timedelta(hours=1), date=date(2016, 1, 1)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 4)) ReportFactory.create(duration=timedelta(hours=2), date=date(2015, 12, 31)) url = reverse("month-statistic-list") - result = auth_client.get(url, data={"ordering": "year,month"}) + result = internal_employee_client.get(url, data={"ordering": "year,month"}) assert result.status_code == 200 json = result.json() diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index 119a41939..364ca7330 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -5,14 +5,14 @@ from timed.tracking.factories import ReportFactory -def test_project_statistic_list(auth_client, django_assert_num_queries): +def test_project_statistic_list(internal_employee_client, django_assert_num_queries): report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("project-statistic-list") - with django_assert_num_queries(8): - result = auth_client.get( + with django_assert_num_queries(9): + result = internal_employee_client.get( url, data={"ordering": "duration", "include": "project,project.customer"} ) assert result.status_code == 200 diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index b9289daf6..2a571046d 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -6,7 +6,7 @@ from timed.tracking.factories import ReportFactory -def test_task_statistic_list(auth_client, django_assert_num_queries): +def test_task_statistic_list(internal_employee_client, django_assert_num_queries): task_z = TaskFactory.create(name="Z") task_test = TaskFactory.create(name="Test") ReportFactory.create(duration=timedelta(hours=1), task=task_test) @@ -14,8 +14,8 @@ def test_task_statistic_list(auth_client, django_assert_num_queries): ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse("task-statistic-list") - with django_assert_num_queries(10): - result = auth_client.get( + with django_assert_num_queries(11): + result = internal_employee_client.get( url, data={ "ordering": "task__name", diff --git a/timed/reports/tests/test_user_statistic.py b/timed/reports/tests/test_user_statistic.py index 7ef21c96e..3e7d93437 100644 --- a/timed/reports/tests/test_user_statistic.py +++ b/timed/reports/tests/test_user_statistic.py @@ -5,14 +5,16 @@ from timed.tracking.factories import ReportFactory -def test_user_statistic_list(auth_client): - user = auth_client.user +def test_user_statistic_list(internal_employee_client): + user = internal_employee_client.user ReportFactory.create(duration=timedelta(hours=1), user=user) ReportFactory.create(duration=timedelta(hours=2), user=user) report = ReportFactory.create(duration=timedelta(hours=2)) url = reverse("user-statistic-list") - result = auth_client.get(url, data={"ordering": "duration", "include": "user"}) + result = internal_employee_client.get( + url, data={"ordering": "duration", "include": "user"} + ) assert result.status_code == 200 json = result.json() diff --git a/timed/reports/tests/test_year_statistic.py b/timed/reports/tests/test_year_statistic.py index de19beaff..9bcd8e6e6 100644 --- a/timed/reports/tests/test_year_statistic.py +++ b/timed/reports/tests/test_year_statistic.py @@ -5,13 +5,13 @@ from timed.tracking.factories import ReportFactory -def test_year_statistic_list(auth_client): +def test_year_statistic_list(internal_employee_client): ReportFactory.create(duration=timedelta(hours=1), date=date(2017, 1, 1)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) url = reverse("year-statistic-list") - result = auth_client.get(url, data={"ordering": "year"}) + result = internal_employee_client.get(url, data={"ordering": "year"}) assert result.status_code == 200 json = result.json() diff --git a/timed/reports/views.py b/timed/reports/views.py index e71a0caa6..cffc4c79a 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -14,6 +14,7 @@ from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet from timed.mixins import AggregateQuerysetMixin +from timed.permissions import IsAuthenticated, IsInternal, IsSuperUser from timed.reports import serializers from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report @@ -27,6 +28,11 @@ class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): filterset_class = ReportFilterSet ordering_fields = ("year", "duration") ordering = ("year",) + permission_classes = [ + # internal employees or super users may read all customer statistics + (IsInternal | IsSuperUser) + & IsAuthenticated + ] def get_queryset(self): queryset = Report.objects.all() @@ -43,6 +49,11 @@ class MonthStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): filterset_class = ReportFilterSet ordering_fields = ("year", "month", "duration") ordering = ("year", "month") + permission_classes = [ + # internal employees or super users may read all customer statistics + (IsInternal | IsSuperUser) + & IsAuthenticated + ] def get_queryset(self): queryset = Report.objects.all() @@ -62,6 +73,11 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): filterset_class = ReportFilterSet ordering_fields = ("task__project__customer__name", "duration") ordering = ("task__project__customer__name",) + permission_classes = [ + # internal employees or super users may read all customer statistics + (IsInternal | IsSuperUser) + & IsAuthenticated + ] def get_queryset(self): queryset = Report.objects.all() @@ -80,6 +96,11 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): filterset_class = ReportFilterSet ordering_fields = ("task__project__name", "duration") ordering = ("task__project__name",) + permission_classes = [ + # internal employees or super users may read all customer statistics + (IsInternal | IsSuperUser) + & IsAuthenticated + ] prefetch_related_for_field = {"task__project": ["reviewers"]} @@ -100,6 +121,11 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): filterset_class = ReportFilterSet ordering_fields = ("task__name", "duration") ordering = ("task__name",) + permission_classes = [ + # internal employees or super users may read all customer statistics + (IsInternal | IsSuperUser) + & IsAuthenticated + ] prefetch_related_for_field = {"task": ["project__reviewers"]} @@ -120,6 +146,11 @@ class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): filterset_class = ReportFilterSet ordering_fields = ("user__username", "duration") ordering = ("user__username",) + permission_classes = [ + # internal employees or super users may read all customer statistics + (IsInternal | IsSuperUser) + & IsAuthenticated + ] def get_queryset(self): queryset = Report.objects.all() diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 93b9a6062..830346a67 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -1,8 +1,8 @@ """Serializers for the tracking app.""" -from datetime import timedelta +from datetime import date, timedelta from django.contrib.auth import get_user_model -from django.db.models import BooleanField, Case, When +from django.db.models import BooleanField, Case, Q, When from django.utils.duration import duration_string from django.utils.translation import gettext_lazy as _ from rest_framework_json_api import relations, serializers @@ -104,7 +104,9 @@ def _validate_owner_only(self, value, field): if self.instance is not None: user = self.context["request"].user owner = self.instance.user - if getattr(self.instance, field) != value and user != owner: + if ( + getattr(self.instance, field) != value and user != owner + ): # rpragma: no cover raise ValidationError(_(f"Only owner may change {field}")) return value @@ -123,6 +125,8 @@ def validate(self, data): Additionally make sure a report is cannot be verified_by if is still needs review. + + External employees with manager or reviewer role may not create reports. """ user = self.context["request"].user @@ -142,7 +146,7 @@ def validate(self, data): if new_verified_by is not None and new_verified_by != user: raise ValidationError(_("You may only verifiy with your own user")) - if new_verified_by and review: + if new_verified_by and review: # pragma: no cover raise ValidationError( _("Report can't both be set as `review` and `verified`.") ) @@ -153,6 +157,41 @@ def validate(self, data): if not self.instance or billed is None: data["billed"] = task.project.billed + current_employment = Employment.objects.get_at(user=user, date=date.today()) + + if ( + self.context["request"].method == "POST" + and current_employment.is_external + and Task.objects.filter( + Q( + task_assignees__user=user, + task_assignees__is_reviewer=True, + ) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + ) + | Q( + task_assignees__user=user, + task_assignees__is_manager=True, + ) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_manager=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_manager=True, + ) + ).exists() + ): + raise ValidationError( + "User is not a resource on the corresponding task, project or customer" + ) return data class Meta: @@ -330,7 +369,7 @@ def validate(self, data): user = data.get("user", instance and instance.user) try: location = Employment.objects.get_at(user, data.get("date")).location - except Employment.DoesNotExist: + except Employment.DoesNotExist: # pragma: no cover raise ValidationError( _("You can't create an absence on an unemployed day.") ) diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index b6bca65e5..25fa53bc8 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -13,8 +13,11 @@ from timed.tracking.factories import AbsenceFactory, ReportFactory -@pytest.mark.parametrize("is_external, expected, ", [(True, 0), (False, 1)]) -def test_absence_list_authenticated(auth_client, is_external, expected): +@pytest.mark.parametrize( + "is_external", + [True, False], +) +def test_absence_list_authenticated(auth_client, is_external): absence = AbsenceFactory.create(user=auth_client.user) # overlapping absence with public holidays need to be hidden @@ -36,22 +39,11 @@ def test_absence_list_authenticated(auth_client, is_external, expected): json = response.json() - assert len(json["data"]) == expected if not is_external: + assert len(json["data"]) == 1 assert json["data"][0]["id"] == str(absence.id) -def test_absence_list_external_employee(external_employee_client): - AbsenceFactory.create_batch(2) - AbsenceFactory.create(user=external_employee_client.user) - - url = reverse("absence-list") - response = external_employee_client.get(url) - - json = response.json() - assert len(json["data"]) == 0 - - def test_absence_list_superuser(superadmin_client): AbsenceFactory.create_batch(2) diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index 322f62e3d..e5082e263 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -8,11 +8,11 @@ from timed.tracking.factories import ActivityFactory -def test_activity_list(auth_client): - activity = ActivityFactory.create(user=auth_client.user) +def test_activity_list(internal_employee_client): + activity = ActivityFactory.create(user=internal_employee_client.user) url = reverse("activity-list") - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -142,28 +142,28 @@ def test_activity_delete(auth_client, is_external, task_assignee, expected): assert response.status_code == expected -def test_activity_list_filter_active(auth_client): - user = auth_client.user +def test_activity_list_filter_active(internal_employee_client): + user = internal_employee_client.user activity1 = ActivityFactory.create(user=user) activity2 = ActivityFactory.create(user=user, to_time=None, task=activity1.task) url = reverse("activity-list") - response = auth_client.get(url, data={"active": "true"}) + response = internal_employee_client.get(url, data={"active": "true"}) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["data"]) == 1 assert json["data"][0]["id"] == str(activity2.id) -def test_activity_list_filter_day(auth_client): - user = auth_client.user +def test_activity_list_filter_day(internal_employee_client): + user = internal_employee_client.user day = date(2016, 2, 2) ActivityFactory.create(date=day - timedelta(days=1), user=user) activity = ActivityFactory.create(date=day, user=user) url = reverse("activity-list") - response = auth_client.get(url, data={"day": day.strftime("%Y-%m-%d")}) + response = internal_employee_client.get(url, data={"day": day.strftime("%Y-%m-%d")}) assert response.status_code == status.HTTP_200_OK json = response.json() diff --git a/timed/tracking/tests/test_attendance.py b/timed/tracking/tests/test_attendance.py index 186938fe7..fc67e2b7d 100644 --- a/timed/tracking/tests/test_attendance.py +++ b/timed/tracking/tests/test_attendance.py @@ -5,12 +5,12 @@ from timed.tracking.factories import AttendanceFactory -def test_attendance_list(auth_client): +def test_attendance_list(internal_employee_client): AttendanceFactory.create() - attendance = AttendanceFactory.create(user=auth_client.user) + attendance = AttendanceFactory.create(user=internal_employee_client.user) url = reverse("attendance-list") - response = auth_client.get(url) + response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 4ea726999..b659bd114 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -872,10 +872,12 @@ def test_report_reset_verified_and_billed_by_reviewer( "task_assignee__is_reviewer, task_assignee__is_manager, task_assignee__is_resource, is_external, verified, expected", [ (True, False, False, False, True, status.HTTP_403_FORBIDDEN), + (True, False, False, False, False, status.HTTP_204_NO_CONTENT), + (False, True, False, False, False, status.HTTP_204_NO_CONTENT), + (False, True, False, False, True, status.HTTP_403_FORBIDDEN), (False, False, True, False, False, status.HTTP_204_NO_CONTENT), (False, False, True, False, True, status.HTTP_403_FORBIDDEN), - (False, False, True, False, False, status.HTTP_204_NO_CONTENT), - (True, False, False, True, False, status.HTTP_204_NO_CONTENT), + (True, False, False, True, False, status.HTTP_403_FORBIDDEN), (False, True, False, True, False, status.HTTP_403_FORBIDDEN), (False, False, True, True, False, status.HTTP_204_NO_CONTENT), (True, False, False, True, True, status.HTTP_403_FORBIDDEN), @@ -883,7 +885,7 @@ def test_report_reset_verified_and_billed_by_reviewer( (False, False, True, True, True, status.HTTP_403_FORBIDDEN), ], ) -def test_report_delete( +def test_report_delete_own_report( auth_client, report_factory, task_assignee, is_external, verified, expected ): user = auth_client.user @@ -905,6 +907,52 @@ def test_report_delete( assert response.status_code == expected +@pytest.mark.parametrize( + "task_assignee__is_reviewer, task_assignee__is_manager, task_assignee__is_resource, is_external, verified", + [ + (True, False, False, False, True), + (True, False, False, False, False), + (False, True, False, False, False), + (False, True, False, False, True), + (False, False, True, False, False), + (False, False, True, False, True), + (True, False, False, True, False), + (True, False, False, True, True), + (False, True, False, True, False), + (False, True, False, True, True), + (False, False, True, True, False), + (False, False, True, True, True), + ], +) +def test_report_delete_not_report_owner( + auth_client, report_factory, task_assignee, is_external, verified +): + user = auth_client.user + task_assignee.user = user + task_assignee.save() + + user2 = UserFactory.create() + report = report_factory.create(user=user2, task=task_assignee.task) + + if verified: + report.verified_by = UserFactory.create() + report.save() + + if is_external: + EmploymentFactory.create(user=user, is_external=True) + else: + EmploymentFactory.create(user=user, is_external=False) + + url = reverse("report-detail", args=[report.id]) + response = auth_client.delete(url) + # status code 404 is expected, when the user cannot see the specific report + # otherwise the user shouldn't be allowed to delete it, therefore code 403 + assert response.status_code in [ + status.HTTP_403_FORBIDDEN, + status.HTTP_404_NOT_FOUND, + ] + + def test_report_round_duration(db, report_factory): """Should round the duration of a report to 15 minutes.""" report = report_factory.create() diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 2661d8ea9..47d0e804f 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,5 +1,7 @@ """Viewsets for the tracking app.""" +from datetime import date + import django_excel from django.conf import settings from django.db.models import Case, CharField, F, Q, Value, When @@ -10,18 +12,23 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from timed.employment.models import Employment from timed.permissions import ( IsAuthenticated, + IsExternal, + IsInternal, IsNotBilledAndVerfied, IsNotDelete, IsNotTransferred, IsOwner, IsReadOnly, + IsResource, IsReviewer, IsSuperUser, IsSupervisor, IsUnverified, ) +from timed.projects.models import Task from timed.serializers import AggregateObject from timed.tracking import filters, models, serializers @@ -35,8 +42,10 @@ class ActivityViewSet(ModelViewSet): filterset_class = filters.ActivityFilterSet permission_classes = [ # users may not change transferred activities - IsAuthenticated & IsNotTransferred + IsAuthenticated & IsInternal & IsNotTransferred | IsAuthenticated & IsReadOnly + # only external employees with resource role may create not transferred activities + | IsAuthenticated & IsExternal & IsResource & IsNotTransferred ] def get_queryset(self): @@ -55,6 +64,14 @@ class AttendanceViewSet(ModelViewSet): serializer_class = serializers.AttendanceSerializer filterset_class = filters.AttendanceFilterSet + permission_classes = [ + # superuser may edit all reports but not delete + IsSuperUser & IsNotDelete + # internal employees may change own attendances + | IsAuthenticated & IsInternal + # only external employees with resource role may change own attendances + | IsAuthenticated & IsExternal & IsResource + ] def get_queryset(self): """Filter the queryset by the user of the request. @@ -70,19 +87,20 @@ def get_queryset(self): class ReportViewSet(ModelViewSet): """Report view set.""" + serializer_class = serializers.ReportSerializer + filterset_class = filters.ReportFilterSet queryset = models.Report.objects.select_related( "task", "user", "task__project", "task__project__customer" ) - serializer_class = serializers.ReportSerializer - filterset_class = filters.ReportFilterSet permission_classes = [ # superuser may edit all reports but not delete IsSuperUser & IsNotDelete - # reviewer and supervisor may not change reports which are verfied and billed + # reviewer and supervisor may change reports which are not verfied and billed # but not delete them | (IsReviewer | IsSupervisor) & IsNotBilledAndVerfied & IsNotDelete - # owner may only change its own unverified reports - | IsOwner & IsUnverified + # internal employees may only change its own unverified reports + # only external employees with resource role may only change its own unverified reports + | IsOwner & IsUnverified & (IsInternal | (IsExternal & IsResource)) # all authenticated users may read all reports | IsAuthenticated & IsReadOnly ] @@ -101,6 +119,32 @@ class ReportViewSet(ModelViewSet): "not_billable", ) + def get_queryset(self): + """Get filtered reports for external employees.""" + user = self.request.user + current_employment = Employment.objects.get_at(user=user, date=date.today()) + queryset = super().get_queryset() + queryset.select_related( + "task", "user", "task__project", "task__project__customer" + ) + + if not current_employment.is_external: + return queryset + + assigned_tasks = Task.objects.filter( + Q(task_assignees__user=user, task_assignees__is_reviewer=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + ) + ) + queryset = queryset.filter(Q(task__in=assigned_tasks) | Q(user=user)) + return queryset + def update(self, request, *args, **kwargs): """Override so we can issue emails on update.""" @@ -300,24 +344,23 @@ class AbsenceViewSet(ModelViewSet): serializer_class = serializers.AbsenceSerializer filterset_class = filters.AbsenceFilterSet - permission_classes = [ # superuser can change all but not delete IsAuthenticated & IsSuperUser & IsNotDelete # owner may change all its absences - | IsAuthenticated & IsOwner + | IsAuthenticated & IsOwner & IsInternal # all authenticated users may read filtered result | IsAuthenticated & IsReadOnly ] def get_queryset(self): + """Get absences only for internal employees.""" user = self.request.user + if user.is_superuser: + queryset = models.Absence.objects.select_related("type", "user") + return queryset - queryset = models.Absence.objects.select_related("type", "user") - - if not user.is_superuser: - queryset = queryset.filter( - Q(user=user) | Q(user__in=user.supervisees.all()) - ) - + queryset = models.Absence.objects.select_related("type", "user").filter( + Q(user=user) | Q(user__in=user.supervisees.all()) + ) return queryset From 89def71eefc0f18e7989b34f882acd2fd619998d Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 27 Jul 2021 15:13:25 +0200 Subject: [PATCH 839/980] feat: use assignees instead of reviewers --- timed/employment/models.py | 15 +++- timed/employment/tests/test_user.py | 9 ++- timed/projects/admin.py | 1 - timed/projects/filters.py | 38 ++++++++++- .../0013_remove_project_reviewers.py | 17 +++++ timed/projects/models.py | 1 - timed/projects/serializers.py | 56 ++++++++++++--- timed/projects/tests/test_project.py | 8 +-- timed/projects/urls.py | 3 + timed/projects/views.py | 32 +++++++-- .../commands/notify_reviewers_unverified.py | 25 ++++--- .../tests/test_notify_reviewers_unverified.py | 14 ++-- timed/reports/views.py | 4 -- timed/tracking/filters.py | 52 +++++++++++--- timed/tracking/serializers.py | 19 +++++- timed/tracking/tests/test_report.py | 68 ++++++++++++++----- timed/tracking/views.py | 4 +- 17 files changed, 285 insertions(+), 81 deletions(-) create mode 100644 timed/projects/migrations/0013_remove_project_reviewers.py diff --git a/timed/employment/models.py b/timed/employment/models.py index d886ab291..61505c61f 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext_lazy as _ from timed.models import WeekdaysField +from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee from timed.tracking.models import Absence @@ -323,8 +324,12 @@ def all_supervisors(self): return objects.filter(supervisees_count__gt=0) def all_reviewers(self): - objects = self.model.objects.annotate(reviews_count=models.Count("reviews")) - return objects.filter(reviews__gt=0) + all_users = self.all() + all_reviewers = [] + for user in all_users: + if user.is_reviewer: + all_reviewers.append(user.id) + return all_users.filter(id__in=all_reviewers) def all_supervisees(self): objects = self.model.objects.annotate( @@ -355,7 +360,11 @@ class User(AbstractUser): @property def is_reviewer(self): - return self.reviews.exists() + return ( + TaskAssignee.objects.filter(user=self, is_reviewer=True).exists() + or ProjectAssignee.objects.filter(user=self, is_reviewer=True).exists() + or CustomerAssignee.objects.filter(user=self, is_reviewer=True).exists() + ) @property def user_id(self): diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index eca57eb44..3e67d6ccd 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -9,7 +9,7 @@ EmploymentFactory, UserFactory, ) -from timed.projects.factories import ProjectFactory +from timed.projects.factories import ProjectAssigneeFactory, ProjectFactory from timed.tracking.factories import AbsenceFactory, ReportFactory @@ -31,7 +31,7 @@ def test_user_list(db, internal_employee_client, django_assert_num_queries): url = reverse("user-list") - with django_assert_num_queries(8): + with django_assert_num_queries(14): response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -207,8 +207,7 @@ def test_user_is_reviewer_filter(internal_employee_client, value, expected): user = UserFactory.create() project = ProjectFactory.create() UserFactory.create_batch(3) - - project.reviewers.add(user) + ProjectAssigneeFactory.create(user=user, project=project, is_reviewer=True) res = internal_employee_client.get(reverse("user-list"), {"is_reviewer": value}) assert len(res.json()["data"]) == expected @@ -235,7 +234,7 @@ def test_user_attributes(internal_employee_client, project): res = internal_employee_client.get(url) assert not res.json()["data"]["attributes"]["is-reviewer"] - project.reviewers.add(user) + ProjectAssigneeFactory.create(user=user, project=project, is_reviewer=True) res = internal_employee_client.get(url) assert res.json()["data"]["attributes"]["is-reviewer"] diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 49a170631..98c7dfcf3 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -115,7 +115,6 @@ class ProjectAdmin(NestedModelAdmin): search_fields = ["name", "customer__name"] inlines = [TaskInline, RedmineProjectInline, ProjectAssigneeInline] - exclude = ("reviewers",) def has_delete_permission(self, request, obj=None): return obj and not obj.tasks.exists() diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 58551e0c6..049870b92 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -1,7 +1,7 @@ """Filters for filtering the data of the projects app endpoints.""" from datetime import date, timedelta -from django.db.models import Count +from django.db.models import Count, Q from django_filters.constants import EMPTY_VALUES from django_filters.rest_framework import Filter, FilterSet, NumberFilter @@ -24,7 +24,41 @@ class ProjectFilterSet(FilterSet): """Filter set for the projects endpoint.""" archived = NumberFilter(field_name="archived") - reviewer = NumberFilter(field_name="reviewers") + # reviewer = NumberFilter(field_name="reviewers") + has_manager = NumberFilter(method="filter_has_manager") + has_reviewer = NumberFilter(method="filter_has_reviewer") + + def filter_has_manager(self, queryset, name, value): + if not value: + return queryset + return queryset.filter( + Q( + pk__in=models.ProjectAssignee.objects.filter( + is_manager=True, user_id=value + ).values("project_id"), + ) + | Q( + customer_id__in=models.CustomerAssignee.objects.filter( + is_manager=True, user_id=value + ).values("customer_id"), + ) + ) + + def filter_has_reviewer(self, queryset, name, value): + if not value: + return queryset + return queryset.filter( + Q( + pk__in=models.ProjectAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("project_id"), + ) + | Q( + customer_id__in=models.CustomerAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("customer_id"), + ) + ) class Meta: """Meta information for the project filter set.""" diff --git a/timed/projects/migrations/0013_remove_project_reviewers.py b/timed/projects/migrations/0013_remove_project_reviewers.py new file mode 100644 index 000000000..c080cd78a --- /dev/null +++ b/timed/projects/migrations/0013_remove_project_reviewers.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.7 on 2021-08-03 11:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0012_migrate_reviewers_to_assignees"), + ] + + operations = [ + migrations.RemoveField( + model_name="project", + name="reviewers", + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index 04f0834ea..e3c13e2fb 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -94,7 +94,6 @@ class Project(models.Model): null=True, related_name="projects", ) - reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="reviews") customer_visible = models.BooleanField(default=False) amount_offered = MoneyField( max_digits=10, decimal_places=2, default_currency="CHF", blank=True, null=True diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index b460093e6..a5e543933 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -13,10 +13,6 @@ class CustomerSerializer(ModelSerializer): """Customer serializer.""" - included_serializers = { - "assignees": "timed.employment.serializers.UserSerializer", - } - class Meta: """Meta information for the customer serializer.""" @@ -28,7 +24,6 @@ class Meta: "website", "comment", "archived", - "assignees", ] @@ -54,8 +49,6 @@ class ProjectSerializer(ModelSerializer): "customer": "timed.projects.serializers.CustomerSerializer", "billing_type": "timed.projects.serializers.BillingTypeSerializer", "cost_center": "timed.projects.serializers.CostCenterSerializer", - "reviewers": "timed.employment.serializers.UserSerializer", - "assignees": "timed.employment.serializers.UserSerializer", } def get_root_meta(self, resource, many): @@ -88,9 +81,7 @@ class Meta: "customer", "billing_type", "cost_center", - "reviewers", "customer_visible", - "assignees", ] @@ -103,7 +94,6 @@ class TaskSerializer(ModelSerializer): "activities": "timed.tracking.serializers.ActivitySerializer", "project": "timed.projects.serializers.ProjectSerializer", "cost_center": "timed.projects.serializers.CostCenterSerializer", - "assignees": "timed.employment.serializers.UserSerializer", } def get_root_meta(self, resource, many): @@ -173,5 +163,49 @@ class Meta: "archived", "project", "cost_center", - "assignees", ] + + +class CustomerAssigneeSerializer(ModelSerializer): + """Customer assignee serializer.""" + + included_serializers = { + "user": "timed.employment.serializers.UserSerializer", + "customer": "timed.projects.serializers.CustomerSerializer", + } + + class Meta: + """Meta information for the customer assignee serializer.""" + + model = models.CustomerAssignee + fields = ["user", "customer", "is_reviewer", "is_manager", "is_resource"] + + +class ProjectAssigneeSerializer(ModelSerializer): + """Project assignee serializer.""" + + included_serializers = { + "user": "timed.employment.serializers.UserSerializer", + "project": "timed.projects.serializers.ProjectSerializer", + } + + class Meta: + """Meta information for the project assignee serializer.""" + + model = models.ProjectAssignee + fields = ["user", "project", "is_reviewer", "is_manager", "is_resource"] + + +class TaskAssigneeSerializer(ModelSerializer): + """Task assignees serializer.""" + + included_serializers = { + "user": "timed.employment.serializers.UserSerializer", + "task": "timed.projects.serializers.TaskSerializer", + } + + class Meta: + """Meta information for the task assignee serializer.""" + + model = models.TaskAssignee + fields = ["user", "task", "is_reviewer", "is_manager", "is_resource"] diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 48bda94f7..b9756f2a1 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -6,7 +6,7 @@ from rest_framework import status from timed.employment.factories import UserFactory -from timed.projects.factories import ProjectFactory +from timed.projects.factories import ProjectAssigneeFactory, ProjectFactory from timed.projects.serializers import ProjectSerializer @@ -27,12 +27,12 @@ def test_project_list_not_archived(internal_employee_client): def test_project_list_include( internal_employee_client, django_assert_num_queries, project ): - users = UserFactory.create_batch(2) - project.reviewers.add(*users) + user = UserFactory.create() + ProjectAssigneeFactory.create(user=user, project=project, is_reviewer=True) url = reverse("project-list") - with django_assert_num_queries(11): + with django_assert_num_queries(2): response = internal_employee_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, diff --git a/timed/projects/urls.py b/timed/projects/urls.py index 2ffc857cb..534ba1dbf 100644 --- a/timed/projects/urls.py +++ b/timed/projects/urls.py @@ -12,5 +12,8 @@ r.register(r"tasks", views.TaskViewSet, "task") r.register(r"billing-types", views.BillingTypeViewSet, "billing-type") r.register(r"cost-centers", views.CostCenterViewSet, "cost-center") +r.register(r"task-assignees", views.TaskAsssigneeViewSet, "task-assignee") +r.register(r"project-assignees", views.ProjectAsssigneeViewSet, "project-assignee") +r.register(r"customer-assignees", views.CustomerAsssigneeViewSet, "customer-assignee") urlpatterns = r.urls diff --git a/timed/projects/views.py b/timed/projects/views.py index ccc604435..ea092a705 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -4,7 +4,6 @@ from django.db.models import Q from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet -from rest_framework_json_api.views import PreloadIncludesMixin from timed.employment.models import Employment from timed.permissions import ( @@ -74,7 +73,7 @@ def get_queryset(self): return models.CostCenter.objects.all() -class ProjectViewSet(PreloadIncludesMixin, ReadOnlyModelViewSet): +class ProjectViewSet(ReadOnlyModelViewSet): """Project view set.""" serializer_class = serializers.ProjectSerializer @@ -83,11 +82,6 @@ class ProjectViewSet(PreloadIncludesMixin, ReadOnlyModelViewSet): ordering = "name" queryset = models.Project.objects.all() - prefetch_for_includes = { - "__all__": ["reviewers"], - "reviewers": ["reviewers__supervisors"], - } - def get_queryset(self): """Get only assigned projects, if an employee is external.""" user = self.request.user @@ -148,3 +142,27 @@ def get_queryset(self): | Q(project__assignees=user) | Q(project__customer__assignees=user) ) + + +class TaskAsssigneeViewSet(ReadOnlyModelViewSet): + serializer_class = serializers.TaskAssigneeSerializer + + def get_queryset(self): + # return models.TaskAssignee.objects.prefetch_related("task", "user") + return models.TaskAssignee.objects.all() + + +class ProjectAsssigneeViewSet(ReadOnlyModelViewSet): + serializer_class = serializers.ProjectAssigneeSerializer + + def get_queryset(self): + # return models.ProjectAssignee.objects.prefetch_related("project", "user") + return models.ProjectAssignee.objects.all() + + +class CustomerAsssigneeViewSet(ReadOnlyModelViewSet): + serializer_class = serializers.CustomerAssigneeSerializer + + def get_queryset(self): + # return models.CustomerAssignee.objects.prefetch_related("customer", "user") + return models.CustomerAssignee.objects.all() diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index 60516afd4..abb1fcc0b 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model from django.core.mail import EmailMessage, get_connection from django.core.management.base import BaseCommand -from django.db.models import Count +from django.db.models import Q from django.template.loader import get_template from timed.tracking.models import Report @@ -86,13 +86,7 @@ def _get_unverified_reports(self, start, end): Unverified reports are reports on project which have a reviewer assigned but are not verified in given time frame. """ - queryset = Report.objects.filter( - date__range=[start, end], verified_by__isnull=True - ) - queryset = queryset.annotate(num_reviewers=Count("task__project__reviewers")) - queryset = queryset.filter(num_reviewers__gt=0) - - return queryset + return Report.objects.filter(date__range=[start, end], verified_by__isnull=True) def _notify_reviewers(self, start, end, reports, optional_message, cc): """Notify reviewers on their unverified reports.""" @@ -104,7 +98,20 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): messages = [] for reviewer in reviewers: - if reports.filter(task__project__reviewers=reviewer).exists(): + if reports.filter( + Q( + task__task_assignees__user=reviewer, + task__task_assignees__is_reviewer=True, + ) + | Q( + task__project__project_assignees__user=reviewer, + task__project__project_assignees__is_reviewer=True, + ) + | Q( + task__project__customer__customer_assignees__user=reviewer, + task__project__customer__customer_assignees__is_reviewer=True, + ) + ).exists(): body = template.render( { # we need start and end date in system format diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 62712069b..807f46fa2 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -4,7 +4,7 @@ from django.core.management import call_command from timed.employment.factories import UserFactory -from timed.projects.factories import ProjectFactory, TaskFactory +from timed.projects.factories import ProjectAssigneeFactory, ProjectFactory, TaskFactory from timed.tracking.factories import ReportFactory @@ -23,14 +23,18 @@ def test_notify_reviewers_with_cc_and_message(db, mailoutbox, cc, message): # a reviewer which will be notified reviewer_work = UserFactory.create() project_work = ProjectFactory.create() - project_work.reviewers.add(reviewer_work) + ProjectAssigneeFactory.create( + user=reviewer_work, project=project_work, is_reviewer=True + ) task_work = TaskFactory.create(project=project_work) ReportFactory.create(date=date(2017, 7, 1), task=task_work, verified_by=None) # a reviewer which doesn't have any unverfied reports reviewer_no_work = UserFactory.create() project_no_work = ProjectFactory.create() - project_no_work.reviewers.add(reviewer_no_work) + ProjectAssigneeFactory.create( + user=reviewer_no_work, project=project_no_work, is_reviewer=True + ) task_no_work = TaskFactory.create(project=project_no_work) ReportFactory.create( date=date(2017, 7, 1), task=task_no_work, verified_by=reviewer_no_work @@ -61,7 +65,9 @@ def test_notify_reviewers(db, mailoutbox): # a reviewer which will be notified reviewer_work = UserFactory.create() project_work = ProjectFactory.create() - project_work.reviewers.add(reviewer_work) + ProjectAssigneeFactory.create( + user=reviewer_work, project=project_work, is_reviewer=True + ) task_work = TaskFactory.create(project=project_work) ReportFactory.create(date=date(2017, 7, 1), task=task_work, verified_by=None) diff --git a/timed/reports/views.py b/timed/reports/views.py index ed55a4e1d..4b201050f 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -102,8 +102,6 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): & IsAuthenticated ] - prefetch_related_for_field = {"task__project": ["reviewers"]} - def get_queryset(self): queryset = Report.objects.all() @@ -127,8 +125,6 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): & IsAuthenticated ] - prefetch_related_for_field = {"task": ["project__reviewers"]} - def get_queryset(self): queryset = Report.objects.all() diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 0ecbc9f08..66ea7da4d 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -12,6 +12,7 @@ NumberFilter, ) +from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee from timed.tracking import models @@ -92,12 +93,33 @@ class ReportFilterSet(FilterSet): verified = NumberFilter( field_name="verified_by_id", lookup_expr="isnull", exclude=True ) - reviewer = NumberFilter(field_name="task__project__reviewers") + reviewer = NumberFilter(method="filter_has_reviewer") verifier = NumberFilter(field_name="verified_by") billing_type = NumberFilter(field_name="task__project__billing_type") user = NumberFilter(field_name="user_id") cost_center = NumberFilter(method="filter_cost_center") + def filter_has_reviewer(self, queryset, name, value): + if not value: + return queryset + return queryset.filter( + Q( + task_id__in=TaskAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("task_id"), + ) + | Q( + task__project_id__in=ProjectAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("project_id"), + ) + | Q( + task__project__customer_id__in=CustomerAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("customer_id"), + ) + ) + def filter_editable(self, queryset, name, value): """Filter reports whether they are editable by current user. @@ -106,13 +128,23 @@ def filter_editable(self, queryset, name, value): """ user = self.request.user - def get_editable_query(): - return ( - # avoid duplicates by using subqueries instead of joins - Q(user__in=user.supervisees.values("id")) - | Q(task__project__in=user.reviews.values("id")) - | Q(user=user) - ) & ~(Q(verified_by__isnull=False) & Q(billed=True)) + editable_filter = ( + # avoid duplicates by using subqueries instead of joins + Q(user__in=user.supervisees.values("id")) + | Q( + task__task_assignees__user=user, + task__task_assignees__is_reviewer=True, + ) + | Q( + task__project__project_assignees__user=user, + task__project__project_assignees__is_reviewer=True, + ) + | Q( + task__project__customer__customer_assignees__user=user, + task__project__customer__customer_assignees__is_reviewer=True, + ) + | Q(user=user) + ) & ~(Q(verified_by__isnull=False) & Q(billed=True)) if value: # editable if user.is_superuser: @@ -120,7 +152,7 @@ def get_editable_query(): return queryset # only owner, reviewer or supervisor may change unverified reports - queryset = queryset.filter(get_editable_query()) + queryset = queryset.filter(editable_filter).distinct() return queryset else: # not editable @@ -128,7 +160,7 @@ def get_editable_query(): # no reports which are not editable return queryset.none() - queryset = queryset.exclude(get_editable_query()) + queryset = queryset.exclude(editable_filter) return queryset def filter_cost_center(self, queryset, name, value): diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 830346a67..941ea7fee 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -136,7 +136,24 @@ def validate(self, data): review = data.get("review") billed = data.get("billed") is_reviewer = ( - user.is_superuser or task.project.reviewers.filter(id=user.id).exists() + user.is_superuser + or Task.objects.filter( + Q( + task_assignees__user=user, + task_assignees__is_reviewer=True, + task_assignees__task=task, + ) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + project__project_assignees__project=task.project, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + project__customer__customer_assignees__customer=task.project.customer, + ) + ).exists() ) if new_verified_by != current_verified_by: diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index b659bd114..059e4a81d 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -9,7 +9,7 @@ from rest_framework import status from timed.employment.factories import EmploymentFactory, UserFactory -from timed.projects.factories import TaskAssigneeFactory +from timed.projects.factories import ProjectAssigneeFactory, TaskAssigneeFactory def test_report_list( @@ -176,7 +176,9 @@ def test_report_list_filter_reviewer( ): user = internal_employee_client.user report = report_factory.create(user=user) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) url = reverse("report-list") @@ -252,12 +254,18 @@ def test_report_list_filter_editable_reviewer( # reviewers and report is created by current user report = report_factory.create(user=user) other_user = user_factory.create() - report.task.project.reviewers.add(user) - report.task.project.reviewers.add(other_user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) + ProjectAssigneeFactory.create( + user=other_user, project=report.task.project, is_reviewer=True + ) # 3rd case: report by other user and current user # is the reviewer reviewer_report = report_factory.create() - reviewer_report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=reviewer_report.task.project, is_reviewer=True + ) url = reverse("report-list") @@ -519,7 +527,9 @@ def test_report_update_bulk_verify_reviewer( ): user = internal_employee_client.user report = report_factory.create(user=user) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) url = reverse("report-bulk") @@ -644,7 +654,9 @@ def test_report_update_date_reviewer( ): user = internal_employee_client.user report = report_factory.create() - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) data = { "data": { @@ -666,7 +678,9 @@ def test_report_update_duration_reviewer( ): user = internal_employee_client.user report = report_factory.create(duration=timedelta(hours=2)) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) data = { "data": { @@ -707,7 +721,9 @@ def test_report_update_verified_and_review_reviewer( ): user = internal_employee_client.user report = report_factory.create(duration=timedelta(hours=2)) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) data = { "data": { @@ -754,7 +770,9 @@ def test_report_update_reviewer( ): user = internal_employee_client.user report = report_factory.create(user=user) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) data = { "data": { @@ -822,8 +840,11 @@ def test_report_reset_verified_by_reviewer( ): """Test that reviewer may not change verified report.""" user = internal_employee_client.user - report = report_factory.create(user=user, verified_by=user) - report.task.project.reviewers.add(user) + reviewer = UserFactory.create() + report = report_factory.create(user=user, verified_by=reviewer) + ProjectAssigneeFactory.create( + user=reviewer, project=report.task.project, is_reviewer=True + ) data = { "data": { @@ -849,7 +870,9 @@ def test_report_reset_verified_and_billed_by_reviewer( """Test that reviewer may not change verified and billed report.""" user = internal_employee_client.user report = report_factory.create(user=user, verified_by=user) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) # Billed is not set on create because the factory doesnt seem to work with that report.billed = True report.save() @@ -1126,7 +1149,7 @@ def test_report_update_bulk_verify_reviewer_multiple_notify( mailoutbox, ): reviewer = internal_employee_client.user - project.reviewers.add(reviewer) + ProjectAssigneeFactory.create(user=reviewer, project=project, is_reviewer=True) user1, user2, user3 = user_factory.create_batch(3) report1_1 = report_factory(user=user1, task=task) @@ -1199,7 +1222,12 @@ def test_report_update_reviewer_notify( report = report_factory(user=reviewer, review=True) else: report = report_factory(user=user, review=True) - report.task.project.reviewers.set([reviewer, user]) + ProjectAssigneeFactory.create( + user=reviewer, project=report.task.project, is_reviewer=True + ) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) new_task = task_factory(project=report.task.project) task = report.task TaskAssigneeFactory.create(user=user, is_resource=True, task=task) @@ -1250,7 +1278,7 @@ def test_report_notify_rendering( ): reviewer = internal_employee_client.user user = user_factory() - project.reviewers.add(reviewer) + ProjectAssigneeFactory.create(user=reviewer, project=project, is_reviewer=True) task1, task2, task3 = task_factory.create_batch(3, project=project) report1 = report_factory( @@ -1330,7 +1358,9 @@ def test_report_update_bulk_bill_reviewer( ): user = internal_employee_client.user report = report_factory.create(user=user) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) url = reverse("report-bulk") @@ -1433,7 +1463,9 @@ def test_report_update_billed(internal_employee_client, report_factory, task): def test_report_update_bulk_billed(internal_employee_client, report_factory, task): user = internal_employee_client.user report = report_factory.create(user=user) - report.task.project.reviewers.add(user) + ProjectAssigneeFactory.create( + user=user, project=report.task.project, is_reviewer=True + ) task.project.billed = True task.project.save() diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 47d0e804f..2ba6e0f41 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -103,6 +103,8 @@ class ReportViewSet(ModelViewSet): | IsOwner & IsUnverified & (IsInternal | (IsExternal & IsResource)) # all authenticated users may read all reports | IsAuthenticated & IsReadOnly + # external employees with resource or reviewer role may edit own unverified reports + | IsExternal & (IsResource | IsReviewer) & IsOwner & IsUnverified ] ordering = ("date", "id") ordering_fields = ( @@ -350,7 +352,7 @@ class AbsenceViewSet(ModelViewSet): # owner may change all its absences | IsAuthenticated & IsOwner & IsInternal # all authenticated users may read filtered result - | IsAuthenticated & IsReadOnly + | IsAuthenticated & IsReadOnly & IsInternal ] def get_queryset(self): From e1e2746c540be03cf0a53f03a91b7ca1555e6d70 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 6 Aug 2021 16:47:00 +0200 Subject: [PATCH 840/980] fix: fix various tests add test for full coverage fix wrong status code or num_queries in tests fix possibilty to create empty reports/activities for external employees add filters for assignees --- timed/employment/models.py | 17 ++++--- timed/mixins.py | 2 +- timed/permissions.py | 36 +++++++-------- timed/projects/filters.py | 44 +++++++++++++++++-- .../projects/tests/test_customer_assignee.py | 18 ++++++++ timed/projects/tests/test_project.py | 21 +++++++++ timed/projects/tests/test_project_assignee.py | 18 ++++++++ timed/projects/tests/test_task_assignee.py | 18 ++++++++ timed/projects/views.py | 12 ++--- .../reports/tests/test_customer_statistic.py | 4 +- timed/reports/tests/test_project_statistic.py | 2 +- timed/reports/tests/test_task_statistic.py | 2 +- timed/tracking/filters.py | 2 +- timed/tracking/serializers.py | 4 +- timed/tracking/tests/test_activity.py | 27 ++++++++++++ timed/tracking/tests/test_report.py | 6 +-- timed/tracking/views.py | 4 +- 17 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 timed/projects/tests/test_customer_assignee.py create mode 100644 timed/projects/tests/test_project_assignee.py create mode 100644 timed/projects/tests/test_task_assignee.py diff --git a/timed/employment/models.py b/timed/employment/models.py index 61505c61f..7021716c0 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -324,12 +324,17 @@ def all_supervisors(self): return objects.filter(supervisees_count__gt=0) def all_reviewers(self): - all_users = self.all() - all_reviewers = [] - for user in all_users: - if user.is_reviewer: - all_reviewers.append(user.id) - return all_users.filter(id__in=all_reviewers) + return self.all().filter( + models.Q( + pk__in=TaskAssignee.objects.filter(is_reviewer=True).values("user") + ) + | models.Q( + pk__in=ProjectAssignee.objects.filter(is_reviewer=True).values("user") + ) + | models.Q( + pk__in=CustomerAssignee.objects.filter(is_reviewer=True).values("user") + ) + ) def all_supervisees(self): objects = self.model.objects.annotate( diff --git a/timed/mixins.py b/timed/mixins.py index 68d3b5f78..4ccdbe371 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -62,7 +62,7 @@ def get_serializer(self, data, *args, **kwargs): qs = value.model.objects.filter(id__in=obj_ids) qs = qs.select_related() - if hasattr(self, "prefetch_related_for_field"): + if hasattr(self, "prefetch_related_for_field"): # pragma: no cover qs = qs.prefetch_related( *self.prefetch_related_for_field.get(source, []) ) diff --git a/timed/permissions.py b/timed/permissions.py index 2c5cab230..c872df202 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -287,23 +287,23 @@ def has_object_permission(self, request, view, obj): if isinstance(obj, tracking_models.Activity) or isinstance( obj, tracking_models.Report ): - task = obj.task - task = obj.task + if obj.task: + return ( + projects_models.Task.objects.filter(pk=obj.task.pk) + .filter( + Q(task_assignees__user=user, task_assignees__is_resource=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_resource=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_resource=True, + ) + ) + .exists() + ) + else: # pragma: no cover + return True else: # pragma: no cover raise RuntimeError("IsResource permission called on unsupported model") - - return ( - projects_models.Task.objects.filter(pk=task.pk) - .filter( - Q(task_assignees__user=user, task_assignees__is_resource=True) - | Q( - project__project_assignees__user=user, - project__project_assignees__is_resource=True, - ) - | Q( - project__customer__customer_assignees__user=user, - project__customer__customer_assignees__is_resource=True, - ) - ) - .exists() - ) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 049870b92..1c4da965d 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -24,12 +24,11 @@ class ProjectFilterSet(FilterSet): """Filter set for the projects endpoint.""" archived = NumberFilter(field_name="archived") - # reviewer = NumberFilter(field_name="reviewers") has_manager = NumberFilter(method="filter_has_manager") has_reviewer = NumberFilter(method="filter_has_reviewer") def filter_has_manager(self, queryset, name, value): - if not value: + if not value: # pragma: no cover return queryset return queryset.filter( Q( @@ -45,7 +44,7 @@ def filter_has_manager(self, queryset, name, value): ) def filter_has_reviewer(self, queryset, name, value): - if not value: + if not value: # pragma: no cover return queryset return queryset.filter( Q( @@ -118,3 +117,42 @@ class Meta: model = models.Task fields = ["archived", "project", "my_most_frequent", "reference", "cost_center"] + + +class TaskAssigneeFilterSet(FilterSet): + """Filter set for the task assignees endpoint.""" + + task = NumberFilter(field_name="task") + user = NumberFilter(field_name="user") + + class Meta: + """Meta information for the task assignee filter set.""" + + model = models.TaskAssignee + fields = ["task", "user", "is_reviewer", "is_manager", "is_resource"] + + +class ProjectAssigneeFilterSet(FilterSet): + """Filter set for the project assignees endpoint.""" + + project = NumberFilter(field_name="project") + user = NumberFilter(field_name="user") + + class Meta: + """Meta information for the project assignee filter set.""" + + model = models.ProjectAssignee + fields = ["project", "user", "is_reviewer", "is_manager", "is_resource"] + + +class CustomerAssigneeFilterSet(FilterSet): + """Filter set for the customer assignees endpoint.""" + + customer = NumberFilter(field_name="customer") + user = NumberFilter(field_name="user") + + class Meta: + """Meta information for the customer assignee filter set.""" + + model = models.CustomerAssignee + fields = ["customer", "user", "is_reviewer", "is_manager", "is_resource"] diff --git a/timed/projects/tests/test_customer_assignee.py b/timed/projects/tests/test_customer_assignee.py new file mode 100644 index 000000000..b3901bdd9 --- /dev/null +++ b/timed/projects/tests/test_customer_assignee.py @@ -0,0 +1,18 @@ +from django.urls import reverse +from rest_framework.status import HTTP_200_OK + +from timed.projects.factories import CustomerAssigneeFactory + + +def test_customer_assignee_list(internal_employee_client): + customer_assignee = CustomerAssigneeFactory.create() + url = reverse("customer-assignee-list") + + res = internal_employee_client.get(url) + assert res.status_code == HTTP_200_OK + json = res.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(customer_assignee.id) + assert json["data"][0]["relationships"]["customer"]["data"]["id"] == str( + customer_assignee.customer.id + ) diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index b9756f2a1..ec4a1c98a 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -125,3 +125,24 @@ def test_project_list_external_employee( json = response.json() assert len(json["data"]) == expected + + +def test_project_filter(internal_employee_client): + user = internal_employee_client.user + proj1, proj2, *_ = ProjectFactory.create_batch(4) + ProjectAssigneeFactory.create(project=proj1, user=user, is_reviewer=True) + ProjectAssigneeFactory.create(project=proj1, user=user, is_manager=True) + + url = reverse("project-list") + + response = internal_employee_client.get(url, data={"has_manager": user.id}) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 + + response = internal_employee_client.get(url, data={"has_reviewer": user.id}) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 diff --git a/timed/projects/tests/test_project_assignee.py b/timed/projects/tests/test_project_assignee.py new file mode 100644 index 000000000..e6c23e700 --- /dev/null +++ b/timed/projects/tests/test_project_assignee.py @@ -0,0 +1,18 @@ +from django.urls import reverse +from rest_framework.status import HTTP_200_OK + +from timed.projects.factories import ProjectAssigneeFactory + + +def test_project_assignee_list(internal_employee_client): + project_assignee = ProjectAssigneeFactory.create() + url = reverse("project-assignee-list") + + res = internal_employee_client.get(url) + assert res.status_code == HTTP_200_OK + json = res.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(project_assignee.id) + assert json["data"][0]["relationships"]["project"]["data"]["id"] == str( + project_assignee.project.id + ) diff --git a/timed/projects/tests/test_task_assignee.py b/timed/projects/tests/test_task_assignee.py new file mode 100644 index 000000000..919c7c55b --- /dev/null +++ b/timed/projects/tests/test_task_assignee.py @@ -0,0 +1,18 @@ +from django.urls import reverse +from rest_framework.status import HTTP_200_OK + +from timed.projects.factories import TaskAssigneeFactory + + +def test_task_assignee_list(internal_employee_client): + task_assignee = TaskAssigneeFactory.create() + url = reverse("task-assignee-list") + + res = internal_employee_client.get(url) + assert res.status_code == HTTP_200_OK + json = res.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(task_assignee.id) + assert json["data"][0]["relationships"]["task"]["data"]["id"] == str( + task_assignee.task.id + ) diff --git a/timed/projects/views.py b/timed/projects/views.py index ea092a705..7034b8624 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -146,23 +146,23 @@ def get_queryset(self): class TaskAsssigneeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.TaskAssigneeSerializer + filterset_class = filters.TaskAssigneeFilterSet def get_queryset(self): - # return models.TaskAssignee.objects.prefetch_related("task", "user") - return models.TaskAssignee.objects.all() + return models.TaskAssignee.objects.select_related("task", "user") class ProjectAsssigneeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.ProjectAssigneeSerializer + filterset_class = filters.ProjectAssigneeFilterSet def get_queryset(self): - # return models.ProjectAssignee.objects.prefetch_related("project", "user") - return models.ProjectAssignee.objects.all() + return models.ProjectAssignee.objects.select_related("project", "user") class CustomerAsssigneeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.CustomerAssigneeSerializer + filterset_class = filters.CustomerAssigneeFilterSet def get_queryset(self): - # return models.CustomerAssignee.objects.prefetch_related("customer", "user") - return models.CustomerAssignee.objects.all() + return models.CustomerAssignee.objects.select_related("customer", "user") diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index e8622b8b6..17bb30301 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -11,7 +11,7 @@ def test_customer_statistic_list(internal_employee_client, django_assert_num_que report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("customer-statistic-list") - with django_assert_num_queries(6): + with django_assert_num_queries(4): result = internal_employee_client.get( url, data={"ordering": "duration", "include": "customer"} ) @@ -56,7 +56,7 @@ def test_customer_statistic_detail(internal_employee_client, django_assert_num_q report = ReportFactory.create(duration=timedelta(hours=1)) url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) - with django_assert_num_queries(6): + with django_assert_num_queries(5): result = internal_employee_client.get( url, data={"ordering": "duration", "include": "customer"} ) diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index 364ca7330..bbe2fd264 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -11,7 +11,7 @@ def test_project_statistic_list(internal_employee_client, django_assert_num_quer report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("project-statistic-list") - with django_assert_num_queries(9): + with django_assert_num_queries(4): result = internal_employee_client.get( url, data={"ordering": "duration", "include": "project,project.customer"} ) diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index 2a571046d..dd812d3ef 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -14,7 +14,7 @@ def test_task_statistic_list(internal_employee_client, django_assert_num_queries ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse("task-statistic-list") - with django_assert_num_queries(11): + with django_assert_num_queries(4): result = internal_employee_client.get( url, data={ diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 66ea7da4d..745413707 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -100,7 +100,7 @@ class ReportFilterSet(FilterSet): cost_center = NumberFilter(method="filter_cost_center") def filter_has_reviewer(self, queryset, name, value): - if not value: + if not value: # pragma: no cover return queryset return queryset.filter( Q( diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 941ea7fee..a730bfb76 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -104,9 +104,7 @@ def _validate_owner_only(self, value, field): if self.instance is not None: user = self.context["request"].user owner = self.instance.user - if ( - getattr(self.instance, field) != value and user != owner - ): # rpragma: no cover + if getattr(self.instance, field) != value and user != owner: raise ValidationError(_(f"Only owner may change {field}")) return value diff --git a/timed/tracking/tests/test_activity.py b/timed/tracking/tests/test_activity.py index e5082e263..b94471df8 100644 --- a/timed/tracking/tests/test_activity.py +++ b/timed/tracking/tests/test_activity.py @@ -74,6 +74,33 @@ def test_activity_create(auth_client, is_external, task_assignee, expected): assert int(json["data"]["relationships"]["user"]["data"]["id"]) == int(user.id) +def test_activity_create_no_task_external_employee(auth_client, task_assignee): + user = auth_client.user + EmploymentFactory(user=user) + task_assignee.user = user + task_assignee.save() + + data = { + "data": { + "type": "activities", + "id": None, + "attributes": { + "from-time": "08:00", + "date": "2017-01-01", + "comment": "Test activity", + }, + } + } + + url = reverse("activity-list") + + response = auth_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED + + json = response.json() + assert int(json["data"]["relationships"]["user"]["data"]["id"]) == int(user.id) + + @pytest.mark.parametrize( "task_assignee__is_resource, task_assignee__is_reviewer, is_external, expected", [ diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 059e4a81d..1bfa7d64f 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -669,7 +669,7 @@ def test_report_update_date_reviewer( url = reverse("report-detail", args=[report.id]) response = internal_employee_client.patch(url, data) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_report_update_duration_reviewer( @@ -693,7 +693,7 @@ def test_report_update_duration_reviewer( url = reverse("report-detail", args=[report.id]) res = internal_employee_client.patch(url, data) - assert res.status_code == status.HTTP_403_FORBIDDEN + assert res.status_code == status.HTTP_400_BAD_REQUEST def test_report_update_by_user( @@ -739,7 +739,7 @@ def test_report_update_verified_and_review_reviewer( url = reverse("report-detail", args=[report.id]) res = internal_employee_client.patch(url, data) - assert res.status_code == status.HTTP_403_FORBIDDEN + assert res.status_code == status.HTTP_400_BAD_REQUEST def test_report_set_verified_by_user( diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 2ba6e0f41..47d0e804f 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -103,8 +103,6 @@ class ReportViewSet(ModelViewSet): | IsOwner & IsUnverified & (IsInternal | (IsExternal & IsResource)) # all authenticated users may read all reports | IsAuthenticated & IsReadOnly - # external employees with resource or reviewer role may edit own unverified reports - | IsExternal & (IsResource | IsReviewer) & IsOwner & IsUnverified ] ordering = ("date", "id") ordering_fields = ( @@ -352,7 +350,7 @@ class AbsenceViewSet(ModelViewSet): # owner may change all its absences | IsAuthenticated & IsOwner & IsInternal # all authenticated users may read filtered result - | IsAuthenticated & IsReadOnly & IsInternal + | IsAuthenticated & IsReadOnly ] def get_queryset(self): From 9b1a6f164f72c2eae57a1e20cc0cff763c7e535a Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 8 Jun 2021 14:42:44 +0200 Subject: [PATCH 841/980] fix(redmine): update template formatting --- timed/redmine/templates/redmine/weekly_report.txt | 2 +- timed/redmine/tests/test_redmine_report.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/timed/redmine/templates/redmine/weekly_report.txt index cf812455f..b92ac82cf 100644 --- a/timed/redmine/templates/redmine/weekly_report.txt +++ b/timed/redmine/templates/redmine/weekly_report.txt @@ -9,5 +9,5 @@ Estimated hours: {{estimated_hours}} Reported in last {{last_days}} days: {% for report in reports %} -{{report.date}} {{report.duration|float_hours|floatformat:2}} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment|ljust:"100"}}{% if report.not_billable %} "Not Billable"{% endif %}{% if report.review %} "Needs Review"{% endif %}{% endfor %} +{{report.date}} {{report.duration|float_hours|floatformat:2}} {% if report.not_billable %}{{"[NB]"|ljust:"6"}}{% else %}{{""|ljust:"6"}}{% endif %}{% if report.review %}{{"[Rev]"|ljust:"6"}}{% else %}{{""|ljust:"6"}}{% endif %} {{report.user.get_full_name|ljust:20}} {{report.task.name|ljust:"20"}} {{report.comment|ljust:"100"}}{% endfor %} ``` diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index b4d23e2da..80b076e92 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -40,8 +40,8 @@ def test_redmine_report(db, freezer, mocker, report_factory, not_billable, revie assert "Estimated hours: {0}".format(estimated_hours) in issue.notes assert "Hours in last 7 days: {0}\n".format(report_hours) in issue.notes assert report.comment in issue.notes - assert "Not Billable" in issue.notes or not not_billable - assert "Needs Review" in issue.notes or not review + assert "[NB]" in issue.notes or not not_billable + assert "[Rev]" in issue.notes or not review assert other.comment not in issue.notes, "Only one new line after report line" issue.save.assert_called_once_with() From 00a4ce4c67d00489f754167d63bbef424d879a53 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 11 Aug 2021 14:43:23 +0200 Subject: [PATCH 842/980] chore(release): v1.2.0 --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4405cfe..c09ff151d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,22 @@ -# v1.2.0 (16 April 2021) +# v1.2.0 (11 August 2021) ### Feature +* Use assignees with reviewer role instead of reviewers ([`89def71`](https://github.com/adfinis-sygroup/timed-backend/commit/89def71eefc0f18e7989b34f882acd2fd619998d)) +* Rewrite permissions and visibilty to use with assignees and external employees ([`159e750`](https://github.com/adfinis-sygroup/timed-backend/commit/159e75033ed4c477d56f2a2817dee82b3066d2a9)) +* Add user assignement to customers, projects and tasks ([`6ff4259`](https://github.com/adfinis-sygroup/timed-backend/commit/6ff425941307a0386d835187eaad02e26cc718e3)) * Export metrics with django-prometheus ([`6ed9cab`](https://github.com/adfinis-sygroup/timed-backend/commit/6ed9cabeeefd2e6945a63b83de1ee85018fb56a5)) +* Add and enable sentry-sdk for error reporting ([`1e96b78`](https://github.com/adfinis-sygroup/timed-backend/commit/1e96b785206ddd1a871e5b23a9126f50c94c38dc)) * Show not_billable and review attributes for reports in weekly report ([`a02aca4`](https://github.com/adfinis-sygroup/timed-backend/commit/a02aca48ae609f9ac514238be723c056fa60754f)) * Add customer_visible field to project serializer ([`2f12f86`](https://github.com/adfinis-sygroup/timed-backend/commit/2f12f86d6132c1362d7065ad0fd8cf89a4f4f377)) * Add billed flag to project and tracking ([`fe41199`](https://github.com/adfinis-sygroup/timed-backend/commit/fe41199527e5ab37f23c715d844805b7d8944d64)) +* **employment:** Add new attribute is_external to employment model ([`e8e6291`](https://github.com/adfinis-sygroup/timed-backend/commit/e8e629193b7aabd592fc9744bc7210577d58c910)) * **projects:** Add currency fields to task and project ([`7266c34`](https://github.com/adfinis-sygroup/timed-backend/commit/7266c346236e9e0d1c83d9f84b99a4e782256ba4)) +* **runtime:** Use gunicorn instead of uwsgi ([`e6b1fdf`](https://github.com/adfinis-sygroup/timed-backend/commit/e6b1fdfc5bb2ad5578ed2927ee210b5da2119f9b)) +* **redmine:** Update template formatting ([`9b1a6f1`](https://github.com/adfinis-sygroup/timed-backend/commit/9b1a6f164f72c2eae57a1e20cc0cff763c7e535a)) ### Fix * Translate work report to English ([`7a87d93`](https://github.com/adfinis-sygroup/timed-backend/commit/7a87d935893dbc68fd59a4fb477691ad209b6a3b)) +* Update workreport template ([`b877194`](https://github.com/adfinis-sygroup/timed-backend/commit/b87719485affd6421734251c270d1fbeb37a7176)) * Add custom forms for supervisor and supervisee inlines ([`b92799d`](https://github.com/adfinis-sygroup/timed-backend/commit/b92799d66759479827cf11f958c12d55d9c8d5bd)) * Add billable column and calculate not billable time ([`4184b76`](https://github.com/adfinis-sygroup/timed-backend/commit/4184b76c66b5233d7a568cc6e37d9112ae9d939f)) * **tracking:** Set billed from project on report ([`d25e64f`](https://github.com/adfinis-sygroup/timed-backend/commit/d25e64fd4c898757acb565996173f460f636c6a6)) From 0bb5b02b9eb132fdd72183c1462293acbe93a42d Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 12 Aug 2021 10:28:48 +0200 Subject: [PATCH 843/980] chore(release): v1.3.0 --- CHANGELOG.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c09ff151d..b4240f907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,29 @@ -# v1.2.0 (11 August 2021) +# v1.3.0 (12 August 2021) ### Feature * Use assignees with reviewer role instead of reviewers ([`89def71`](https://github.com/adfinis-sygroup/timed-backend/commit/89def71eefc0f18e7989b34f882acd2fd619998d)) * Rewrite permissions and visibilty to use with assignees and external employees ([`159e750`](https://github.com/adfinis-sygroup/timed-backend/commit/159e75033ed4c477d56f2a2817dee82b3066d2a9)) * Add user assignement to customers, projects and tasks ([`6ff4259`](https://github.com/adfinis-sygroup/timed-backend/commit/6ff425941307a0386d835187eaad02e26cc718e3)) -* Export metrics with django-prometheus ([`6ed9cab`](https://github.com/adfinis-sygroup/timed-backend/commit/6ed9cabeeefd2e6945a63b83de1ee85018fb56a5)) * Add and enable sentry-sdk for error reporting ([`1e96b78`](https://github.com/adfinis-sygroup/timed-backend/commit/1e96b785206ddd1a871e5b23a9126f50c94c38dc)) +* **employment:** Add new attribute is_external to employment model ([`e8e6291`](https://github.com/adfinis-sygroup/timed-backend/commit/e8e629193b7aabd592fc9744bc7210577d58c910)) +* **runtime:** Use gunicorn instead of uwsgi ([`e6b1fdf`](https://github.com/adfinis-sygroup/timed-backend/commit/e6b1fdfc5bb2ad5578ed2927ee210b5da2119f9b)) +* **redmine:** Update template formatting ([`9b1a6f1`](https://github.com/adfinis-sygroup/timed-backend/commit/9b1a6f164f72c2eae57a1e20cc0cff763c7e535a)) + +### Fix +* Update workreport template ([`b877194`](https://github.com/adfinis-sygroup/timed-backend/commit/b87719485affd6421734251c270d1fbeb37a7176)) + + +# v1.2.0 (16 April 2021) + +### Feature +* Export metrics with django-prometheus ([`6ed9cab`](https://github.com/adfinis-sygroup/timed-backend/commit/6ed9cabeeefd2e6945a63b83de1ee85018fb56a5)) * Show not_billable and review attributes for reports in weekly report ([`a02aca4`](https://github.com/adfinis-sygroup/timed-backend/commit/a02aca48ae609f9ac514238be723c056fa60754f)) * Add customer_visible field to project serializer ([`2f12f86`](https://github.com/adfinis-sygroup/timed-backend/commit/2f12f86d6132c1362d7065ad0fd8cf89a4f4f377)) * Add billed flag to project and tracking ([`fe41199`](https://github.com/adfinis-sygroup/timed-backend/commit/fe41199527e5ab37f23c715d844805b7d8944d64)) -* **employment:** Add new attribute is_external to employment model ([`e8e6291`](https://github.com/adfinis-sygroup/timed-backend/commit/e8e629193b7aabd592fc9744bc7210577d58c910)) * **projects:** Add currency fields to task and project ([`7266c34`](https://github.com/adfinis-sygroup/timed-backend/commit/7266c346236e9e0d1c83d9f84b99a4e782256ba4)) -* **runtime:** Use gunicorn instead of uwsgi ([`e6b1fdf`](https://github.com/adfinis-sygroup/timed-backend/commit/e6b1fdfc5bb2ad5578ed2927ee210b5da2119f9b)) -* **redmine:** Update template formatting ([`9b1a6f1`](https://github.com/adfinis-sygroup/timed-backend/commit/9b1a6f164f72c2eae57a1e20cc0cff763c7e535a)) ### Fix * Translate work report to English ([`7a87d93`](https://github.com/adfinis-sygroup/timed-backend/commit/7a87d935893dbc68fd59a4fb477691ad209b6a3b)) -* Update workreport template ([`b877194`](https://github.com/adfinis-sygroup/timed-backend/commit/b87719485affd6421734251c270d1fbeb37a7176)) * Add custom forms for supervisor and supervisee inlines ([`b92799d`](https://github.com/adfinis-sygroup/timed-backend/commit/b92799d66759479827cf11f958c12d55d9c8d5bd)) * Add billable column and calculate not billable time ([`4184b76`](https://github.com/adfinis-sygroup/timed-backend/commit/4184b76c66b5233d7a568cc6e37d9112ae9d939f)) * **tracking:** Set billed from project on report ([`d25e64f`](https://github.com/adfinis-sygroup/timed-backend/commit/d25e64fd4c898757acb565996173f460f636c6a6)) From 75657afa9c8bc203deaca50a4f30f50ea0ac238a Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 17 Aug 2021 16:48:54 +0200 Subject: [PATCH 844/980] feat(deployment): serve static files This is against django best practices, but this is mainly a DRF application, and the only static files we serve are for the Django-Admin, with a limited user base. Thus, we can serve static files without too much negative impact --- timed/urls.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/timed/urls.py b/timed/urls.py index 1a236034a..8408d7b90 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -1,5 +1,7 @@ """Root URL mapping.""" +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import include, re_path @@ -12,4 +14,4 @@ re_path(r"^api/v1/", include("timed.subscription.urls")), re_path(r"^oidc/", include("mozilla_django_oidc.urls")), re_path(r"^prometheus/", include("django_prometheus.urls")), -] +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) From 53dc8342e00d7473d1aae4ff52b2b4719553e489 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Wed, 18 Aug 2021 12:31:08 +0200 Subject: [PATCH 845/980] fix: update fixtures This will update the fixtures according to #772 --- timed/fixtures/test_data.json | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json index 2b48d20e6..334b4f040 100644 --- a/timed/fixtures/test_data.json +++ b/timed/fixtures/test_data.json @@ -185,8 +185,7 @@ "customer": 1, "billing_type": 1, "cost_center": 1, - "customer_visible": false, - "reviewers": [3] + "customer_visible": false } }, { @@ -201,8 +200,7 @@ "customer": 1, "billing_type": 1, "cost_center": 1, - "customer_visible": false, - "reviewers": [] + "customer_visible": false } }, { @@ -217,8 +215,7 @@ "customer": 2, "billing_type": null, "cost_center": null, - "customer_visible": false, - "reviewers": [] + "customer_visible": false } }, { @@ -297,5 +294,27 @@ "days": 5, "transfer": false } + }, + { + "model": "projects.customerassignee", + "pk": 1, + "fields": { + "user": 2, + "customer": 1, + "is_resource": false, + "is_reviewer": true, + "is_manager": false + } + }, + { + "model": "projects.taskassignee", + "pk": 1, + "fields": { + "user": 3, + "task": 5, + "is_resource": false, + "is_reviewer": true, + "is_manager": true + } } ] From 6abb05fe4d1d76cc33cd27a64eb130f0aec9615e Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Mon, 23 Aug 2021 14:11:55 +0200 Subject: [PATCH 846/980] fix(projects): add manager role to project assignees Until the introduction of assignees, a reviewer could verify reports and manage tasks of a project. Now, this has changed and tasks can only be managed by assignees who have the manager role. --- .../projects/migrations/0012_migrate_reviewers_to_assignees.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py b/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py index c67404648..1e89c09cf 100644 --- a/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py +++ b/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py @@ -10,7 +10,7 @@ def migrate_reviewers(apps, schema_editor): for project in projects: for reviewer in project.reviewers.all(): project_assignee = ProjectAssignee( - user=reviewer, project=project, is_reviewer=True + user=reviewer, project=project, is_reviewer=True, is_manager=True ) project_assignee.save() From 560fcd834722dd94f5150f15b3cb44930d2e156a Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Thu, 26 Aug 2021 13:38:16 +0200 Subject: [PATCH 847/980] feat: add `in` filter to assignees This will allow to filter assignees by the given IDs, which will significally improve performance in the frontend. --- timed/projects/filters.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 1c4da965d..420f6d527 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -3,11 +3,15 @@ from django.db.models import Count, Q from django_filters.constants import EMPTY_VALUES -from django_filters.rest_framework import Filter, FilterSet, NumberFilter +from django_filters.rest_framework import BaseInFilter, Filter, FilterSet, NumberFilter from timed.projects import models +class NumberInFilter(BaseInFilter, NumberFilter): + pass + + class CustomerFilterSet(FilterSet): """Filter set for the customers endpoint.""" @@ -123,6 +127,7 @@ class TaskAssigneeFilterSet(FilterSet): """Filter set for the task assignees endpoint.""" task = NumberFilter(field_name="task") + tasks = NumberInFilter(field_name="task") user = NumberFilter(field_name="user") class Meta: @@ -136,6 +141,7 @@ class ProjectAssigneeFilterSet(FilterSet): """Filter set for the project assignees endpoint.""" project = NumberFilter(field_name="project") + projects = NumberInFilter(field_name="project") user = NumberFilter(field_name="user") class Meta: @@ -149,6 +155,7 @@ class CustomerAssigneeFilterSet(FilterSet): """Filter set for the customer assignees endpoint.""" customer = NumberFilter(field_name="customer") + customers = NumberInFilter(field_name="customer") user = NumberFilter(field_name="user") class Meta: From 7135a3da635ee7f54f344395e069e4d89657349d Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 1 Sep 2021 14:10:15 +0200 Subject: [PATCH 848/980] fix(reports): use correct columns for calculations --- timed/reports/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index 4b201050f..8050c982f 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -290,14 +290,14 @@ def _create_workreport(self, from_date, to_date, today, project, reports, user): ) # calculate location of total hours as insert rows moved it - table[13 + len(reports) + len(tasks), 2].formula = "of:=SUM(B13:B{0})".format( + table[13 + len(reports) + len(tasks), 2].formula = "of:=SUM(C13:C{0})".format( str(13 + len(reports) - 1) ) # calculate location of total not billable hours as insert rows moved it table[ 13 + len(reports) + len(tasks) + 1, 2 - ].formula = 'of:=SUMIF(C13:C{0};"no";B13:B{0})'.format( + ].formula = 'of:=SUMIF(F13:F{0};"no";C13:C{0})'.format( str(13 + len(reports) - 1) ) From d60852dd5cc97286a9aa27d3e5e671295cb03fa2 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 1 Sep 2021 16:10:11 +0200 Subject: [PATCH 849/980] feat(employment): add is_accountant flag for user change billing permission from reviewers to superuser and accountants --- timed/employment/admin.py | 4 ++- timed/employment/filters.py | 9 ++++++- .../migrations/0015_user_is_accountant.py | 18 +++++++++++++ timed/employment/models.py | 2 ++ timed/employment/serializers.py | 2 ++ timed/permissions.py | 10 ++++++++ timed/tracking/serializers.py | 4 +-- timed/tracking/tests/test_report.py | 25 +++++++++++++++++++ timed/tracking/views.py | 13 +++++----- 9 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 timed/employment/migrations/0015_user_is_accountant.py diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 2f9e391aa..9e7c37157 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -153,7 +153,9 @@ class UserAdmin(UserAdmin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fieldsets += ((_("Extra fields"), {"fields": ["tour_done"]}),) + self.fieldsets += ( + (_("Extra fields"), {"fields": ["tour_done", "is_accountant"]}), + ) def disable_users(self, request, queryset): queryset.update(is_active=False) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 99036726b..d57202320 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -48,6 +48,7 @@ class UserFilterSet(FilterSet): supervisor = NumberFilter(field_name="supervisors") is_reviewer = NumberFilter(method="filter_is_reviewer") is_supervisor = NumberFilter(method="filter_is_supervisor") + is_accountant = NumberFilter(field_name="is_accountant") def filter_is_reviewer(self, queryset, name, value): if value: @@ -61,7 +62,13 @@ def filter_is_supervisor(self, queryset, name, value): class Meta: model = models.User - fields = ["active", "supervisor", "is_reviewer", "is_supervisor"] + fields = [ + "active", + "supervisor", + "is_reviewer", + "is_supervisor", + "is_accountant", + ] class EmploymentFilterSet(FilterSet): diff --git a/timed/employment/migrations/0015_user_is_accountant.py b/timed/employment/migrations/0015_user_is_accountant.py new file mode 100644 index 000000000..7b5f17cbf --- /dev/null +++ b/timed/employment/migrations/0015_user_is_accountant.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.7 on 2021-09-01 15:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("employment", "0014_employment_is_external"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_accountant", + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/employment/models.py b/timed/employment/models.py index 7021716c0..80b8b2361 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -361,6 +361,8 @@ class User(AbstractUser): May also be name of organization if need to. """ + is_accountant = models.BooleanField(default=False) + objects = UserManager() @property diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index d3e47591c..ea1071f25 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -42,6 +42,7 @@ class Meta: "tour_done", "username", "is_reviewer", + "is_accountant", ] read_only_fields = [ "first_name", @@ -53,6 +54,7 @@ class Meta: "supervisors", "username", "is_reviewer", + "is_accountant", ] diff --git a/timed/permissions.py b/timed/permissions.py index c872df202..8490a967f 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -307,3 +307,13 @@ def has_object_permission(self, request, view, obj): return True else: # pragma: no cover raise RuntimeError("IsResource permission called on unsupported model") + + +class IsAccountant(IsAuthenticated): + """Allows access only to accountants.""" + + def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): # pragma: no cover + return False + + return request.user.is_accountant diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index a730bfb76..42b1af6f6 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -166,8 +166,8 @@ def validate(self, data): _("Report can't both be set as `review` and `verified`.") ) - if not is_reviewer and billed: - raise ValidationError(_("Only reviewers may bill reports.")) + if not user.is_accountant and billed: + raise ValidationError(_("Only accountants may bill reports.")) if not self.instance or billed is None: data["billed"] = task.project.billed diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 1bfa7d64f..91d59a61a 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1375,6 +1375,31 @@ def test_report_update_bulk_bill_reviewer( response = internal_employee_client.post( url + "?editable=1&reviewer={0}".format(user.id), data ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + report.refresh_from_db() + assert not report.billed + + +def test_report_update_bulk_bill_accountant( + internal_employee_client, + report_factory, +): + user = internal_employee_client.user + user.is_accountant = True + user.save() + report = report_factory.create(user=user) + url = reverse("report-bulk") + + data = { + "data": { + "type": "report-bulks", + "id": None, + "attributes": {"billed": True}, + } + } + + response = internal_employee_client.post(url + "?editable=1", data) assert response.status_code == status.HTTP_204_NO_CONTENT report.refresh_from_db() diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 47d0e804f..11c492262 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -14,6 +14,7 @@ from timed.employment.models import Employment from timed.permissions import ( + IsAccountant, IsAuthenticated, IsExternal, IsInternal, @@ -93,8 +94,8 @@ class ReportViewSet(ModelViewSet): "task", "user", "task__project", "task__project__customer" ) permission_classes = [ - # superuser may edit all reports but not delete - IsSuperUser & IsNotDelete + # superuser and accountants may edit all reports but not delete + (IsSuperUser | IsAccountant) & IsNotDelete # reviewer and supervisor may change reports which are not verfied and billed # but not delete them | (IsReviewer | IsSupervisor) & IsNotBilledAndVerfied & IsNotDelete @@ -241,13 +242,11 @@ def bulk(self, request): _("Reports can't both be set as `review` and `verified`.") ) - if ( - serializer.validated_data.get("billed", None) is not None - and not user.is_superuser - and str(request.query_params.get("reviewer")) != str(user.id) + if serializer.validated_data.get("billed", None) is not None and not ( + user.is_superuser or user.is_accountant ): raise exceptions.ParseError( - _("Reviewer filter needs to be set to verifying user") + _("Only superuser and accountants may bill reports") ) if "task" in fields: From 4c3b40e9ae0e25ae79edc9af8d0c610d569b4fc2 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 23 Sep 2021 16:42:40 +0200 Subject: [PATCH 850/980] fix: add X_FORWARDED_PROTO header --- timed/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/timed/settings.py b/timed/settings.py index 354fb8342..937f51cac 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -354,3 +354,5 @@ def parse_admins(admins): # something more human-readable. # release="myapp@1.0.0", ) + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') From 1ba81c4d77331ad3691ee6aaa863828014f1e067 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 23 Sep 2021 16:47:44 +0200 Subject: [PATCH 851/980] fix: fix black formatting --- timed/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/settings.py b/timed/settings.py index 937f51cac..4445fe32c 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -355,4 +355,4 @@ def parse_admins(admins): # release="myapp@1.0.0", ) -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") From c12354ad31af9f4eb62538f07a592bedb4c1ce84 Mon Sep 17 00:00:00 2001 From: Lucas Bickel Date: Mon, 27 Sep 2021 19:42:21 +0200 Subject: [PATCH 852/980] fix: use whitenoise middleware to host admin statics --- requirements.txt | 3 ++- timed/settings.py | 1 + timed/urls.py | 4 +--- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4a7b4e15c..e324d1143 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,5 @@ django-environ==0.4.5 django-money==1.3.1 python-redmine==2.3.0 sentry-sdk==1.0.0 -gunicorn==20.1.0 \ No newline at end of file +gunicorn==20.1.0 +whitenoise==5.3.0 diff --git a/timed/settings.py b/timed/settings.py index 4445fe32c..44cf236d2 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -83,6 +83,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django_prometheus.middleware.PrometheusAfterMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", ] ROOT_URLCONF = "timed.urls" diff --git a/timed/urls.py b/timed/urls.py index 8408d7b90..1a236034a 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -1,7 +1,5 @@ """Root URL mapping.""" -from django.conf import settings -from django.conf.urls.static import static from django.contrib import admin from django.urls import include, re_path @@ -14,4 +12,4 @@ re_path(r"^api/v1/", include("timed.subscription.urls")), re_path(r"^oidc/", include("mozilla_django_oidc.urls")), re_path(r"^prometheus/", include("django_prometheus.urls")), -] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +] From ba1df12d82fb9caec3d305e56b24d52f26ff1e74 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 28 Sep 2021 14:32:07 +0200 Subject: [PATCH 853/980] fix(redmine): fix total hours calculation total_hours was calculated for all reports during updated-range and not for total duration of the project --- .../management/commands/redmine_report.py | 9 +++-- timed/redmine/tests/test_redmine_report.py | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index be175b019..37451e4d6 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -38,7 +38,7 @@ def handle(self, *args, **options): start = end - timedelta(days=last_days) # get projects with reports in given last days - projects = ( + affected_projects = ( Project.objects.filter( archived=False, redmine_project__isnull=False, @@ -46,8 +46,13 @@ def handle(self, *args, **options): ) .annotate(count_reports=Count("tasks__reports")) .filter(count_reports__gt=0) - .annotate(total_hours=Sum("tasks__reports__duration")) + .values("id") + ) + # calculate total hours + projects = ( + Project.objects.filter(id__in=affected_projects) .order_by("name") + .annotate(total_hours=Sum("tasks__reports__duration")) ) for project in projects: diff --git a/timed/redmine/tests/test_redmine_report.py b/timed/redmine/tests/test_redmine_report.py index 80b076e92..81b7e78f9 100644 --- a/timed/redmine/tests/test_redmine_report.py +++ b/timed/redmine/tests/test_redmine_report.py @@ -83,3 +83,38 @@ def test_redmine_report_invalid_issue(db, freezer, mocker, capsys, report_factor _, err = capsys.readouterr() assert "issue 1000 assigned" in err + + +def test_redmine_report_calculate_total_hours( + db, freezer, mocker, task, report_factory +): + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + freezer.move_to("2017-07-15") + reports = report_factory.create_batch(10, task=task) + + freezer.move_to("2017-07-24") + reports_last_seven_days = report_factory.create_batch(10, task=task) + + total_hours_last_seven_days = 0 + for report in reports_last_seven_days: + total_hours_last_seven_days += report.duration.total_seconds() / 3600 + + total_hours = 0 + for report in reports + reports_last_seven_days: + total_hours += report.duration.total_seconds() / 3600 + + RedmineProject.objects.create(project=task.project, issue_id=1000) + + freezer.move_to("2017-07-31") + call_command("redmine_report", last_days=7) + + redmine_instance.issue.get.assert_called_once_with(1000) + assert "Total hours: {0}".format(total_hours) in issue.notes + assert ( + "Hours in last 7 days: {0}\n".format(total_hours_last_seven_days) in issue.notes + ) From bd3596767ce57055307fdff3b0aa6698175918d9 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 30 Sep 2021 14:53:30 +0200 Subject: [PATCH 854/980] fix(reports): add logo and udpate font in workreport --- timed/reports/templates/workreport.ots | Bin 13002 -> 159977 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index 4b488e54031e2b4beb90d70f9f6e4deeffee3b9c..5e8f22caba03645c7a08aac40178decfbf30ac4c 100644 GIT binary patch literal 159977 zcmeEvby!qg_cjh7Eh(U&APv$W9V3Wz2%;i2h@_N+G&rKt-64!hh)PLGgNT%bAkrbN zGzdt4XP6oF@kQU?GroU(f9Q3oBgeDPK5MUauY0f9qoIt6MS+2Vi-B?Rx|EiC%wsiS z3=9nTZ{SB5))v+#j;?klMs{{q7RE-77PdA#w`{I++Zs7oIB?tAnb=&nHFmN#v2o;f zu(LNYy6#|ZV&cf%h-rYGn)DW6$&N z?gAd}bj|2@cO5_AS$4K|PImBH|K-yhZEdao{nOxg*%{fGSpDW{2hGyK(a6!s;rsJ& zadG#T>6;jV53C2gosqGziIoW;SX+A@V<&ri`1-h5TVY~oDC5AF6$1qS1Nil8QPTof ze_kLvds{Pm6NeurgwmV+!H|E@9dM_GwUL>L1CNY_qqUKp15yeHy$vC7v$c_pg{g^y zBe(r^)0W5<8y{Yhy9*NcOj>)uJlNRwY)vJWDb;80U%}>CP9Phq42isSA@%9!l=oxV z=LLpC3KHjxXd41wJgK=TXw1&sq+nk7>9klGcS%~mSYJ=kX}W|HOO`%qDIBHFHXNdc zG55HH39Z`lAF;V4tVpPrGt_j6+*BZZQxJlScQRPUVs+f(xM=Grp6TTajx(?@*L8Zy zz(KK0*b984BFzflBKmMBI2cc*>8_&>ZK;}aoj}aW1<{&^bEEZ;icX(L5|G8yA#FWF zeuU{01|t=@^LjqR9?|A*^TuvYopnpYj8Tvz{(1G!W#X=TQ@h-4d&fp|3T~aV0=GUk z+YFm3SfBGXg0FUPWV1~_FNxgx4*uDeOOFh~ zxnxX7xD!-wQ~D7&nl+4HF;a>IDasvb56w9zIg4fBm~kxGbwK&4he*oZ^}~I3%)(x@ z=PPj{INq34EP2mW;@sGSda!pbHLyd{&D}v3KIA=3Gs4H7n+d_I(=8XXMmY?3aidT(K&1d$P{FF{_zK(d`_4 z#1PrWipRKEggZn5wbU)oH7%jOT<0tDg9w9{WXh(!nN@~FNjul0Kl-bi zI^$%eItFCkZ(2LoUWUWuA0grwKp%YT=KW5>`kOCX1GI0ZP+jo1xfrB>`)0r4b?|T| zhHM1hPSkG0i%Vrv<5S%1$`&jeT0QjgOsVDZw5;{+{uhj~11@a~p;rro zi#<8t$%N)xhP4Z8CP*y#+^18MYO3YbyVp2K9y{l{=l)<}gE%;Inn5*egnJpI; zXB@WvCXxGK5oh45p)Bp>L8?sM>86Yc9h&}Tff%i}32P@35{)jUe#2;K7Ixgc@c{PB zBekYf6yao^FSQ+Kh#B_wtyD%j>~blv-rm1mZ+yU992{M(OdQ}V-lc2p(EpNjcgXOe zQM$0Rz&%B!Wdf%>erx-ZnKMfRuPwHg4zrxaCsMQnx4&30_HNsGx*NfbamSW{{c6!{ z(yYHMB(KMF4ZDnKb+bjEtjz|mja`|CYyEYeDbuPBxO2K=b82dR`juP$Zs!fk`|nww z-QJLs(S+LHq>J=89N6g*eKVq#o-#nbrt@g6Xv<}GHHAvD-2fA&Gs>6CZd|g|m7Jg2 zkkPwJbLvI3wQX^KH?`huFz2`c5(;;VDD4~>6A-pYuaJ<4_ zF;cO5=@ti-B0;rK?8@^Gm$KWzr-P?L94s9o__zrRFM548jVMaX670S1$$ZnnNd_yq z^h$?N_fVbSPKTLR_lksW%cWGB@Yl7e_8-FTTRoLWVM+`Qj)mLv2kyp;1a@UBhmvm$>O$BMj!) zv>`iv&s;9nxika_wl8IFgqH=`ab(y%kIA}9TY8r7>7ALLi1ZiC%z5u%40HXIagUhf z1&`j%x9YkwSd^(JJ(z7-{KgGxo9-LHV3VyDIDUVzaSfw?;T+AAk`sk{Fa$5Cg$6FQ zn^-sQFMTGF3Us`z!aH}$)_b>pc5@EqsQ-#Jvh^fB)LySU;(XxkyD$Bk@jLCfv9)#Z zHkd-VWRs0T7(?=jnnu9%dKzrXs}vfTR51^4Pl7U*!FFmfLj!3RL7W56_xon z$1b0v=`+z+-Puvn$;8=Z7rHQ_RmHF1I{1o$mYXWcGkp0qVaS`yffK##23G}iu&Oss z)OWqz7$Dfucvy;k$k$?#(P$_4L^+*_NI z^rz!LD{@NPE@s40Q0t=m#1~~rhRfkp3Dk|cA)6)bjQ=Jj>APY;IDrmbcl z)n2F!CAy{`7UN`b+OL`MRi4raaV4cJk^=yk+hbr6DN9-O^P(&mLAYWeT}+gsAcNLx0u4joE(~QuEM_CW5E%sc~qu$ zXE1s9IrnY1(!~ds1q@QTbUDWeohr}BJ;Ssa;U#QR$7-(UW`xC6Vy=8XMreuIH%-_i zCp)OB@|a@c^I@`rqhDHir`la1z1X*jGhR8;j_H$Q>9u_6SoSZM6dy2l#^1?*(cWz= zc3JCC`L>&jvU$NFi)sfS9LCL=fsL*+o7jTBunKvufOvP3&u^}glZ8K)5?5`N3!P^l z3%h#DiKm|UotISh&i1{66uJvrZS*aVWg1UTde&iy`W2H|N}NBtKr`zdjw5#*wn+_W14$Degc(yaFK zJ#sk5SZSt`s*bsqYru@9_2sydGVzfX_b^%Mb%{sU_$M3YgQNL;&bZv!Vn62p@WM#} z@QXC42aItco=Afb&x!4BHz<%n`qJIKhAVa?3!2YmuMMng2dU%9)r*SHG-lKj)}7YG zH7ILvfNHW-vcp`4@_m+tw`UC)KE~ntEvzS()?1q( zX0pHyLr}9mMskyUaEQ7j^L4yeEY(NB2$sbpeUI5z0=kJbK{-&M=XI8#bEIcU@Sl@# zJ&-c>zp+ZuTnz9ayRbB4NeVxdUSc*%feUCxjZnt#T!wCyM?EuIeRjPIBnxoXM0p zv7vSLJq(6dT{y-o{&|;A;`0q`>niVlpT_(o1v`VhCz_U`Y@)%Ep%&-Qske5G7SBX) zb}_A1_&ncEw8ecV@xgYe+|}9{r#bvNrfNNVs^?a5em{4ieEJO;?D*)*n({k6mUGAX zjvI?dd^#b*&qMi-z9C zm(VzA zuU{!^?z&cDudl#7^l8AMwRN2G^r|lV;M4=_o#fXh#13l4{ADFz$t`_%0|)3y@d$zb zosu{6dqq8)dvKy{&APIF77y5k2>p&|69B}^*w)4oxwD$n({<<(;BZN?gBb5$y_h3Ui zixh|Z7?~(OnrG`)RA$#S)SV32F4^s`l{bF0XSZ7H>{4=$JIS?2Rfd|cwvKdc??#gN zqx?8jGqFU+r%fkZ~kc%HDHx5?G4OhQ1^rf**Jb8boHHO}PtNw)k$Nv;M28~27KwxZsp6sb*BS5fwLS!xGzZqOCVQPNx+4!L%Fv)Eq6 za;MuP3J+3dqy+JT_>4pg7v(!^u&m{Di|`=Ep4d0z#qSadnot&)3}s-*8NN57cXQ}q zwh?O}CkkS|B!bP87*M0!YDU%SNDy~Z287q)ph^v{kx_kScJ$M2KB47_XYRWa|uTx)k@Z^Pkz`98g)iCHY{z~rX#1Wj?_fh9WQTCY z%`(Y4#J=ZI%H`{+75ITMsT8X&SlL8LAJ3GQBZD#Ac=5t}pGVdHA2aXN2zAcSsZ2>q zcYeBR+T3AZ^C4%s@GO2@f?RDx4rvgx-NTP(g<+Q$IuJj4076KB_PaU?h7M^UFrLu%B32r4dL4!at0fp=onZWXy<#R`8*V8fG0~j*Fcx|Wb zZe1REK4do%6pA_JXkIcFMpP;zG_BM6TI$F^u6W9k`(qz(j+`IdaGX_Pt%uU2Q0WnR zJOK&E(;l;roF{Z<5_-gO6D#LNwTtpG?LOw?mq|8Gvm7qwa(^u^9VGIwy_!$<6mAgz z78w@BNgurndulQ^s`9n-VW$JeEE?(~BcTEjwD)u`h^fDtTO@)wQ(k`G)%nQ7GPq1O zo2@kCR7%h+@P9d${Vzz(laxF%a86E<2bn><`Pyh?tGnA?lV~M|FWAP=<6TjG7IFSl z&3#{U+S{s^c=;_xzaEmSB)t}@jPF-NS*FqH5H>6Fy64`uE3DhEVAh|fFHQ1mHTSr? zg~41W`CSsZH_5F*s$T*kPo)q~oucC#x^c8Zsz#ABv^%h)7{>^zKpQ`gX}!AT7R@Fe z$%8krOc`|&qH(JA*imXsk_|lPcY5oPchjRx*Y3KHY-T*NE(@=DP426znbBdMn4}r< z#`7+t;k2HNOV=|D?oFBOw$F}hL|Er)tmmo6s+;hxKJ5}Cyi?A4_*^+(n1$Kq6aBFCkA6qRjNGF-7$iE!Wr6~oo-Hw{M#&|hMSH6yyK9)8sqKCIhkNG=ul$O0NK`5#p1e732{(_JrX}P~O+$>BPEDMMF{zWq3Ec zmbRYZE}0RG&IHWauR0kq8Q$=AKV_R~5Kut4Q6ns2T^TWvyc0XKyBG8HR#~l20pdVP6`3SXZo%{(LJsuH<`pY@9w4^6B z6-a5_ZF-Z=MD#sys%YnSqruj>QohaP5Y*CtqPO71MvAMF!p@L?-~)mb@r#x>?h@%3 zl)ZaCcxywXrBrK1C2@L+xJ(D~`e6J^bIQ#4gc?C#v5JlTZ~Mp)iZ*H?|{%=mYd zd@x$OFyG*le>obc+_`%1Vsv(L(5dBe`q;@)jL2Ef=U=(sV7_iHy~A`a{Hx~usm)=6 zWj|7|PG%#H5$>c|j=3uV)Sx5Pq)N*P(&pya7>kmdKXP8nwN7y63dA zsGB49sWP|NO7mFn{Mh+SH)2SQ!y$X-&JXs-8`gI$98}tGaWtg8JL*?Al9_#XCDqdV zjK;XXld`mf634tGzC=sM%Jg6eV|;e1i~lT*iwW9gNGJ$f$K zzIT3JZp4^DcS8g`$}u3MAh>a=-AZmGBGgAOhD1SCSw(fUpLA*Av1Irj@$CBJ#_O9y zc^N)5%|%|c17AiPx&=r_Q)A7r$etCJ2WP+TKFX^H=CK8N;8u$;huH8f7OfM#`jYFC zt*nN zxzja9)GA@1TxP%;)ut8pw$-w0@QL=l`;sEyr$yKH98Zf;)v54pJ=;2Gs^mJdn zj@gcgR`+DpY~Ll9Y13eA8=;HyvJSQT!m%iUu{oiQ;Tn1etYAaHB0VViFrBbIW9nyVFU1Glw3{)gLS&x2yRvOpZ;~!GwW{hls4MzP zMfZA%r>OV>w3Lt+(sk0+w1U`8Ntb?MeRD_Pt*(3IYQ)e9r}@EVcOh%4D%VIXNcPx- z)$YnvV#=qVI`!s;$XSZwuS{NVHMf~4xpvmnC%T^MxNam!cX_L=eD;PDTik`i$uJ;b z7^;OLwy5a+rf#Y^rzi*m{`(9-Y&9&59i4zcKMx--@Qa^!|1aM2{37sw@(S|H2#JV@ z$_Vg_3ZCQR=Q}HSj@!=0%wI!Io(TUqJR(V?sBlgb1A~|i0|VfxqzV z6m%RgFt{0k-%N*u$20RVKq5nm=gwYqIW#*cX)u1}IM1|THnI3^zHB0K`Zp&Tdk@{r z;?oib7v9l~H`acSpTPe{%^q8|-~n+A}~k|&eo=8hVZz{dgG9>Q*_ z$aG}-(~-c?7dQU=rG`NGQSMrw6#jIi+aQzfKUgK?B3EEju&t+eg#YnK*iG5!s|-rx z+z8k-=2g=e|9B+)D1FR7S7b^J*g&F^Jld9kdMygJvcrFBRV33~FmQ=*bjTmS7MsQ` zBGk;#o>jXp7;10JaRTp8N78`J9{)4E0cHRzz73ifx+q5Wry~J?bLLNB;xFL*&S3rm z&aZX!%U{6xr62kWIKOs^e;LkioZ?@=`3pF|M3vw8`oDnl7jV#`$A1CmFW~$IoNvhb zcT4qa9Q_-^`3-UV8^ifEj{c3|{5pL47jXUp4qC+RFW~$I9CV1=U%>ebIDY{LVL5-Z zRKG@$|F5%D0o8lkRl6z89`PByxK<2{U-ovY_SS1zFuyQ=+sgf$4^Om!1mS}zxr_S@ zA7%)?PTn#MBTbnmPF=n!wo68FCtku??UUaok$weJrmGXM#1a zN8>yC&lGj^+?D$^NBqs3w8JN^&Nhg!Xy_h(s)8#pTi3OLZRecJSRe2`TAc)%S65yZ zWgPjN|KUsC{*e=aG64P*_fP!(6s zC!Idsp1v<6li@>~q`w*A*O%Ti0+Ua6I7POfJLDAsmQ*UEy+C6Q_;?(!qej;>#|@mK?w~jR*Fa$W zs<5832!*pc)qef$a1pgxLjg9WqPLJWsF(cLZ~p59`h8Xy*wtl9!;Lf zcVen-o)V~m&kziZm&Q+Qx;__G$U{%+U4(>}^ROnA z6`+;h_o2{$Ni=k_nC~y9MhZCj&Q?^$$K9BQ`OkrB=Z+HSjh9+D>=YcWXA}HguK1TW z1f>FsXbd4PiU@(va<*T;-(Qd~NCT||sRl%qQLyuD0gbx9hT`8K!-yj=wz`?E2|^Ra zpqh}Y$@ddqd!5v`ClgBrvzsq;Oj@8f zM2S2knsj~*Z8vW&~|C(w6Z+in6K8Lvjig@uJ8AvjpVe!5mmZnN43PESlTFzCV zDkpo7)<0~2SVA>=^$T8aJMF5N7ZYo+c}BGG;d(f=sguNydjl;+$MxWZfj5llV2}W*`sHi!GIRps+b}rntKl%`CSXWCDBF0&*T~~yv>gY4O7d%| zP;ODY6PhC&-7V!3_qM_U;}X$YO^5)nnu)HTR1t!+;%UD=%{j-}avqigjzy>;`pcOM zEGO~IjavwjLYTuqol_JEw>H**CSD>erL*plAJTi#+C^Y-kN}dYQ6vHFOC~N19IH2F zK?OjRC-%J7KqZ^pyeG~y0&)KB1MEZqk@)7H5kSDsq7N8fC6-;#I)AI1?#M<=wHTE9 zgiW`TeG*y%A6bM8#nuWNA(WAO;D9J9cFkn7?v}to1F|cui=y2voZW3>r6D2c0SoOV zFm&0-vqcTDq~%=grt96s#J$QR`cSzLy-N4@zs1vEqA>I^E}MoFe!d)%CB}j#*k8IH zpSSIGUbnC6#`=H@cX9SRjyE<%Reo)UzhN_WU_kOXug{!8O8y}@@^P_++5W2b0e}ct z?5sFg8a)f$0T}2|4nXwx$pSNrwx;i8k)BNwqt znWPdemzP<7Bh+7BN&Sv`R}Ax!DB6!F zcq|?4DqhAOYN?gF4Y1IODAI8xNdCL|ixdD0DNB%;f>7X|D=316xgdPfsyYgrJHi8f z*LH*nAco-FbYu+T7UeX*Y~-yNQ^xkoFvXi&k0?skPr&GYYkR*EShXqOe1umzkX%6| z0_oab4CKgP?cg6ns35w`a0CPH+HPAaA^p8Hh+Wg*c|cUon!sZn76z1KJGUIog3jzm zo&dv#Hd*r_hbMrt7+2cxwT4*`I4bVB^zn~dExB`g1UMd9X5~Fcbs~zKII)y%NUsY6iMIBPj@UZnhdA0&tyEiBJ254*B zF9abLms5bX%LPPD^*XIwD~`qbfQO7C=ykuli5{C+1$42S<`WW3-!r!}B~^7FjW9n< zpQ{iQi|ME3ht7IJKg0LR-T_$1PMLCw1}(c3z$p*ESeT#i%E5b1`m({PbA0pDhc6Cs zzt@akJRcGaVAmP&5hN;V;|uzK7D5t!VBZNy34I5*cp1=i?=yM6h?zangHgn#0|BkL z^=My?@=`eCj}=l!QyHz;fyj`uNG+1TbpkQU5(y&bWHxR!8viaF7Q4#<{SG@4M4UE= zkk`M3n5U$`j-n-#1Q%(h_o^kn!wdQWts*Lb230%=M=VFRKQ<7dtOr7ry02b}-eilR zi>X%4PVCo{Ca%TTE%?Qn{g%l<#jq&Yx^JF5hl<4U@V2!}7tz^_pTjO4-{E~XkcMP< z|IW$vz*(o7$6=(RWKhCY5ze&8>SML)ukD=0Lq@cG0+qB3kf1&|80q{*O}~8pqlMN=`EdB=uG<%ND?s8 z^}8L&@W~Th1k;-GUw%5TAa^-_KcE#FUB^s|4zz#^0c3e5c?N`p)glse{>d52F~ayF zaN-xUPL?pqf2-MWi-A=EX1{yM4F$IZu=a*uGOT)di%h=_e38_7v>uUX>YQg``^-4~?UxiPNWnIC)MK(nD6$V`)M*lVmzm z$YyuwL6q0IKva0CiZ{?*O*&v#yNgkO3dt&RLHZ1~9jzT!Sey6*8GjVd;*|qLq9rk7 zk0AVva4qD&m#*5e?KGaLWV;8_PcI+bD=q&>sh>4*^a^s!?8k3pq>begAPz&4w#g!RB&FG1f*rjisD(W{>(8@JY zM+3uNc%asZv`rpBM0NnI{NUj@)Q7p%KN@tl8KGl9)l7hSsxKEPC+vG66(yKz(f^2U zK{EkeNp6adVcoZoxF;yKwX@|aBo?@X4xivP0CapzvX~zUR|dGL z3LB^7nucG@OxD@(;5{dQ*@#FTWJ&q`4m{&)}-3K`RVTeGhK_N#_-j=C6zURsiMRV$pVQ zyiRbWtpbg!?^7ifj<5_i=}C*1!x6hJ=kQW19-v58Kap?%T59eAJfK$EmmitaETW1t zo2Kh07Pfx`0%AgRTB+qp=DQu}#Nk-D0ZkmGy8UHe%Yfhk!n+w>TQq4pVn2Lkgn==k zKnT6SWLy#;?ZyZ8ct{c_ja#tcmHa=6B|tdgI%v-_fuf!8=)MWQY9isT(=$B6gr`8# z(6$rJq+xX`9I-w-D#sue%S9RBSb^H%%U!zlbBv222#U2pTZ~rHgI5Vy%*PU05ex|C z`M;r9yl3D#C_c7~jN1$Wd$%Y$xMy=%TuD4J%d}Vo*FslU{Plj{IkXB)g46+@|FtzU z2S%|0tvdfd_?r-6mfnnI-Kx zgmWv3-|qI|gmmx$v7z}}Z}FPYN_J`hYFKszA?-sTX~4Jbm&M!~-nMHf#*!lYnlxh? zGc&dHSSUhczP52iJAqcTx1CI-<4|cIs&RLEA2Ci47eR(Z#P{=)goB$ z=d;lFZ3^%Obu4;~jK|SxBZ8}QFQ#`cP!;vGV*^oy#1BAG71D$JD^l?oNT^DbYH^_0 zehR>vxz%eQn6(Caw%*8oN8rI;H<#An`6XTnkv#u*5BV8yb#VoctrT;;ImT}dmFq}R zO$4v~tLY-uf*N47p-a!3{1Ir_^9){G5|;7Nt2tHJ{Xx%lV=91TIp}7m@U@UO(#$MYO|M_P=5n9GF=ls z#V&~k*N4ae^JaB?k%o%h{uDv@lWffs9iSwx#Y`RZ@zWPI^zN@>jKhRhg1B~&=w46Bb>l( zGh-kD;D1vR1LXtuJ|}B+qA(F}YVL_;d8H1j6!Cw>^u;qc3(1{dw6emWr;wbfv<;B3(?!T zRnr6JNxWQuhuCnEJUItbrd37d4D06pAdUr1031-aqtI&^#99(6A-8ULo84!zxqGw- zfcU*hOfd~NC5b@_}C1)?vqcx{yY8q z7LcG|t9rkPTugc(T@(2*A6aJyr}}?TgixIc%syq4@CwrFQr|-b5aPEkFfsWX|53W; z@F{>ykYp%^A-x6|tMs*~dYJ;eR@E+LbWQFLA_(m8iE9q`A%}hfgy2e59$F-(dY#a| z79n~c94luNniqBkEs2-GD>Jm)U639CsN&tP^^j=GPNXWUV9He+amY^8$xf+9w~9CE zDxiy*ZMRpEiq3~$p!j))nmn@eEH-8Q&NA$D99qRZxi|n1at8}yBb~rV8j5MH4OR>+ zoPnMJQI*&v0N>(};Dt)K{5wQf;{bBOsoRuDqII6LovHYLM6^bF04Eg6WrK9Q5He&z zOhiS>xmOVbvHSbC1oRvn48FtCPNqDIWW~lL3gPyELKk&>D)17_-cdp~16mgL#1UtcY_& zZkTa@PSyMg(fX;Ff}R9fd>ku{c`6tBC#A>Wqkx7#6Ct{Tq-W{$$kJo`ia41{`+>x2 z{p>_J09Ptn%o)%TOZ5@p0nXI|QOJ-|?hiS`0`!aE3jwy0J)z=gl$wB}s|%)2;R0GS>~_&DGv>xrKmgyfotfzYJdQScBNrU^a)j65Sk z{0J#>7A8c^=WbNg0&7&!MQ5s)4HRg{5t38@9B-WFLRJ^5=)shC|3^g26X484#ZNX+ zW-W>IaFN+nYtz{T&;?K$tU|dA%}NS2;8f2Irddi98S>bSVwk9Vs<06NuUV6t+HYwX zK(o{iT7d`5ohHp6HFsU0(rMM{S@>a99hmZRx7Oh>aWqiHP7JX5jQnDLjMsAfx{dT zdZ!0Nh=DkqK2So@a-0&`|Po z$`FW8{t3^Tl?CimilPNoV%U=cj?CPxtorm;kQ6GnT%M$m-GNTBQw`oK@tU=I8Tm%g zSdc6JycRs9^}ixow}7soa)F0yt(2+f**1Yc0fZT zJk6PGcJEnj*0o7e{DCZ>e?{cgKWMQH^xx3z(lCw>GkpJ-ktXg8alNGtOgj@ru zF7JrtC8d>>ffLzH%hZhz|5mk*aD}}ZYkt1;BAVH3!uy4Ox_%T@w3YC}>!qPx=`z;( zh1%U{D)nc!P2DDaE9)^blgoVvWktbhfG(bvqU&)aNai8-Mq7q;Bjymy!47Vtdi zk-@X2vvj_{U0R@KcYavkEOVux_w$TXr9pbvMUN!ABb5(K?k&{49j@9nd#8PBMLK^> zbdKFRel2e8xr-?NL7jW7fNVTQ$sw*N1Gx`U-Si%^3!2C38z}c~P~Bw4|E+LB7{lBh zV|6}l%AFoIOzYhw&`Z5xA^hyr`ozprmknJ5sSHU^+j;-Iv7jGbt9`H_X6b>&!jX_g`QK<#0T+-ZM}+ zvSd{*i;Z8jM?v}U#--jlEsFx*GaGzzx$8V+8MNVi^VnUXx%~~p!Op}T&gjfEI#D!T z?UaU;RYcWlJO5 zukr{uBSt2ABBOrQ$noR?eO=U)|BTq&R(JWf;OV_cRp><3iESPzG4`ds5Sz|SRd{IE zMdkn(db|1~oTUu);G^E-F`Qnhmu&3nQkID~-Jv`dh+-)*cYoOtWJ&7s7-K^sPd_6% z=Q14BiYTt^Y+3S`!^z0OYDIeiFj3MxE>vlt$Q^Lx8Jo_5+r)RWioub@c8>6}r;`tV z`E3V_>{W(UgtfYJ5?e>s(?@8!?aHDrp{o3mots*2NI+le7I(L+Yx1p)^JNWEz?;dFX~cOjO_Op)ZcVB)D#)V5_~Sc2 z1t3;$hnF#|et-f@8FFxfEU424QIb4S!5`gB_J8+vI5=SiT8d8FfV9PC9(rpI9TSKXN zE0|F3dQ&=DXB1oeS&aylAlmfW*OIe9Rju7C1k28mjbqb4apR=Yo#>QoB$14ndR?i^j=Dzh<|U5fykX%m-Eg#zX8 zzbR9-BxzVEGk)LvmKT>&YUUsCwk+BbA{Cs!@Emvu?x7*k4`V!e&*XtkGbLg`DbaM-th=q zZYiDP1LoFFuWh$zPrxS5v*d0%53^+PFeRiR(!;}+OxzLmU?)7jMh3~q?zWDVZr zHj(oTb6cJxHxk7VO;~4?j{Y+#prRYCS$>(K$?U||FN&K*|$ru}OWEYKW{=)mbG+s~9-pE~L#&~B{e$j_YusMY_WdJKp3 z%3!mVfNP1g$-z}J&_h6E>|}c*83)2h7e8s)Xta$j4jH!2+s_KMtjWO3%DRGgo+0y0 z+})}`$p62!UiX}%Uw_!zxp!)=!o5oLI%95hb?2qWD4G64L=a;j5=9ev3}pvO5U0q} z9mr1GCjl*ujdYF33d3QEYpl?9>^|Cr3hy3Lq^CHLDkb8LC(m1-=iIMOU*pOhSid`Y z(2I{)0}r(=>p@m?yk~D`dLDS@p_8arMS9!#k=RFb1Jiqw{@%%Y%Va}x@O?ILgn)aj zVi1Z9AEY^9TWV7m%sVT8Rd@p#0Za|Y3v>^Wc}ZsoOYDkmEc_I{puH)?{$$qe%Y&=2 zEO@6ilKXUqC5O59(oZhiT9#~GH2q=khk_53`Ncf~&kO6bF39z1Cj?j5U^US#NcD`f z98_jG0@v)zw3SF&#X&hu*00m{wQpd=fbEG#*3rp7ucL#lm26T|xdtwGa@ zDa<{p_*kyOjC+-^K7h>!Nks#WbA4@L$W${_y~Ie8pmvIgttog8;6?crl+(*|5_1gj z^AxIgo=)$T{Fkl!_(?6Ad+uZhKaE!kSlBV~41R?98dFRcDSTG8x#c{OS)&_tVlHg? z;-@ZO46hT`5oF>~;8=fK;xi?#D2jK$m~VhBT*OqPY51CG%lVNjpy80humdw&U%x0q zUpzxD&y%b4!r4=Ji(-d6u>?@AJpL-Y8c;Fj1kaWN^QF0t3X^>27 zzP=n*H0L=)Scsx&sLO~u!E=CC-H?_YMd&#>Wkc}HO6u?&*e+7k2C$&qT3X{1eJpu7 z#;R%tIONm(E2}l8!hAnE>VFPc4a2tgUK7KUNPm#li%Y8uah=7^I^diLNE7hj43D)5 zMDN6BDs!^YY~k#(w@YEB?x)~=%EUO^G5XHPUJLE)M3!##;@{V}cak-Drt0u}DfF+N zv`x#O_#~F)!j7z3bUR*wJ$*Cy)12)Qt%y6|& z4!>srTvvts^lz#)crBp=(h00JhVlFK?2 zrn@(i!@52g_kSqF18SQ^fZArT{wL|~qao%8JTeGg^z*VUXLkP@P)cKu=^j&UyGHE+ zU&jDI^7~V}7 zmZ93de+}3&1!7)zDdu?MAyz#goAN>t*9 zsMZ^?N}q4rz34(^)((O?JI6zHjqoi?IFyuWsTa9^(W zgD?L{%tzoUg6isYWTC_+-uU@7C%2*1nF_6(z#yBBKEeJQP@&=0&LjFv$;?)aWai5Y z#adsa9|>?A{o$ccf%HoSS&vh64UzB@lcBbuBf=ix-#3RM+2Enr0n<9e(6Xmpz^Q&; zsUo?-ZYz47vuY@LxRCxw<^_Q>k@2^kc*^MFGmIH9#9fr zO#y5N)t8<(BdVq@vV-@3z@~|H6O~?1BI5PNl^+oJUb&a8oRRd9_w+(L)YkG9yIr^Jl(!U5o1xw+|SY3_1ycO7-YA3uXXnADs)q}4ROP8cXy*LXHpn``lkljA|>+VR5Au40tP)I^LeOV&U%C$wac#5T!2 zV~}D#XBo>S*DuO5Og5qle7W}X^Qc7oaUXK>ANBk&Coi4dtSnxIDlGpY)@ilx8YEBY z^49ILyg?_ju?U5$C}zWp0n7kXL{SEj2QxmZz&B=Me?u*9IQmYL^Xio7a($Wn3Hn8# z+0$DKo`|pX>@oO0Jb+dJ`&`It?X2p(9#pA|7bRVK=CpT#^|NCU4(3z8AzZn;Arhn^hdiAQSTo?Dqms)#*Q3j7bFO29+{s-A`c+sC znhPuzfD8i_F{B^dq68L2XY%>S`?og2Y~d`;fWUF1NO9%tG<}a7f#Ak^1#r64e>l1Y zJi&z%x!wyPXIJTdH|a9x%jBqiGA^M8#EEBp>d<}e>;DktH|8nw2$%xnHC<$HkPCBr z<>q|BFnnV&gV%s#AA8fne!RQtWT9_$8N3icH4$}=5K=23%FuGw6tgGN$BWX2V`}?? z5^Y64{D5YUNgCY4o0*n*1FkdioqFO{?_S0 z5J8~x`a-vGD9~u;3FY?99+sX(_RJ%Jshl-z_`+L<+0lxzHM)zCYru0!eXV{j2wLzJJ0bPT42~6*{~SNCYlBf(Pu1d9nRQPg z_iax-U{c*}*YxG<|KX=cct9&vvw34zK&tFb4O_hvd6WgHFcGkMJ&v{PuxLH{Ra2Jp z8a+_EBV9~w{LeQ*`ayAcfg)+U9YT3vRbU%S!}N8}JyQ7v*Kq1Y4~#j3@RV$9jDI!oKj>^yW*WoBYt_ve17X%mI}74EZLpzN=%3h#tR!om`&~ zw;#$uAqP^p?*T_wGMUeaNa^rOL2WSD3rh}F!D9(3bwDg(V!KNZvMXjj+(kbkJ9s7u zo9_p2&MOGLWqv4>cgWS=Qa&y=>bvmESr@}FCn`gJ)hF}M@96`AY+aq+QVaa`;csGS z7U0K}x^uNp^t<-S?H`r2rTuQt;irR9Ax}ZBdyeq~w&@Apa;}EsdT&>7`YDgrF;RRs zN*vOLwn|l&HK}p>QWnWa)hndkFDU-gP!54k12D>sIz#OWXfThmuU5?P5_Yt@d)y5{ zt*8v8`ZQIUst^`-e_CZ&vrKXQ)wll$&rs)r#YcMTDcwY=0`r)e>Auf_2ityOfq%G| zXWdm50t5Y$LBcyo+cVS`5e$+VRFWsDo_e{~Z%z8bE!Q(!ZO#VU>>A0m1!5_?0i`#t zkwe=`Y)nhMP6{7O{fF(RGv#s+zD(3?jh?!i{AjkqYJyeK8gvJn~j9s75TxPUh3r(7@1gcOesT`Eg+-&V`o-sItb_J$sla69 z-L9n}qR^HkR|b1_0VCIaqk+%SPjRtGH-v5_m!)wqov84u#KV3yderub^-6!lw^s)~ zJkfzE+6@_ZyWXF~o5^c!KZlBI0}<96P;SNwoZf^s@ORbu@m@Ay68boZpH!d1ru0qP zR&Wr0cBg5IF-zFbL?ROhnClI^=Q~r;hrjaU8i?^YhHvB*d;vUJ%l(C4T zEB8IN6?0gMt*yY~$|WBn292C*t%`^Kh_jVZih)2*7l3qyt`9UJt|eWh7cMfx8<|W! zJ8QPIfE~TEqavKF(-fKJkQ~@t=kg+=krb0HSqbGaYg63S8jGcavQe&s!7`;nz6)`_S) zP4y=xsb|_*{CBLMH#W{dK>DW1C#-Iff9KzT*sp-*a$h~TUNGS-8%$iTzC3{J^9RD| zLQodGjj>u|@n_2iB-a2E=?^6HQgDY}>@N!BEoh_KG;#aQE#tCrLFN1kk_H?HI{V5h z6VyqJKuUhHNKg7_E#)H^uXrc15j^M2W-Mue&m%Q}v+cM~ z)Vmp%wcuQ!^B`GWO(TJa%OWZa4d|9m00}2#n-;OV=#S9_XgL13Hy9siS#b}mbJ2`IZu%m?YnB^253X)Xc_%4}m zA_6{CFTrzl!vg^bt&9Tvh-c#KbbI46`Xevj&%0S z^gkB&JX3oJTWn@p2h+#8_YvROB@pnU^a(Lo#QH$M+3oxeSyTMu1TdhoK*6!p-KV1a zdZ)E!<;qZvCdI)X#wp>H}66Z#|b>GmQ~R>}0pK&VwihP{#pqEP*GV5U_O)J7HY1 zkfvYXzETaaOg6dx4Os0$@9GBfObK?nu*TK=D?deng21q2XOC#9KP0NQ`i#4bj0FJR z?Jii{j}80#-sB&?^rw@%L%{*~Jmjic>gVJGQzf`E6wjJC7aaA?*ND_O%0@ip(M4tf z-+7I4#%B3{Fcak390n>>TzNB;VEgyNu0n;vA86AI8eyKsjwS%2apBFr z4r9w&YeQ91qv9Ys;N-mq!Jn&TjE}~#=4I}lG_jg0P?cHvk>@}!;1xV>I?wM*@>QNi zKf6z>(rY+*`P#-#B9&pv@TRQl1hGh(x`nNEY_ASbgeAt)dU-giTf9o%>vVe8cIA)n zjgqDv1R!NNhlLOa6jA4cZ3J5*h6m#u^dfajbo$cISQK@G^ee49ZdE=^61D0iHNPac z*Vd`1lm1;ixGE~J_$!shYU|t!M!Ix&=~mcdP`PHvOVEbSs?a+_LSfACg$pCPlWqou z86NtRo^OpS=dE=sc-)3=z0%8>I_CahZLGU_*5L$@K}OB)d(;GW37BVL^0AwU9gY!h zK`rhI?1EP#QD>{~1w)#8<72Tb?%tlBubr`^;HU8G1BDy^P};|24O`=`WXm<5FFfdy zPJs=CoM!I*CX%3Yuw#lRJ9<=L6pYeolIhVICt|z1Y$t~7?|A58Z{}`prtr8*OEwF~ zT6FVV{eN^_bzIZm*Vj231q2b5Qjn5VT4E}SASEFkij+zS(&fZP1*995l9q0!h%`e1 zX@;aygAt?m+-;QKpU;bb_F~&-=iYnzyze}uWa00q zGUT*9&n?I-G2l+kH!X>FS|``P9>L?t?}#cXjW9vt8=p}FL<6h2$lV=kz>C4>kY?aW zx8H|Gl|K{kEw*!y8O_+bQV8BQtALdt--~XFT~1RHqgQZ-5?8S0Ewm|j4=;3{Bo!PBrhK5k zv6@Jy_g7xilR@tY`SJl1PtnZVX?b72|9uIwfLycydg@ID)%uxpav@=!ZKIR7i$-uU zBW3lFtA9O*$U(5vfTlB2bYF-vbb#Lau>H!u=;Uy#SII2y`?T6QrrpxAnL4DT$}#ez9NI>@t^H&IPsy8k_J6|U z20;6lb<&r{M8Xhjko4#tEE*dz73ANQ&zpLW`^>D*5J=#wUiTzGiPod}d^fzi|AV>crO4_ed0; z0aO9U+vj#|X+SUbR&R%U6VIZ&*b6`rW%cayTBrNx<4Mdx&&*BUXp~M;=}sFJTq$bfAH;oW{>&p!ncB0a=Aj} z=z5BgCGcpcf>bHtMsMvWaZG>Q{rk8)bB{tpv%TwA9(E?3{Wjf9Y*%J=G zRf}CBU#M;I8giy|LwE3$P&DY&0~olzsL1B!fFCH#X}sJ#y3+bB7fa5vH#OyO3SRCT zY4rzFw!e1)8kK0Y$G#vwLGAJa*X(M44A{kD+DF`HoMde!`gr!R+s+P=w}bG%q-FjT zAK_h%V8r`mGg8KDd7zTTs@F$$un1peXgq}_Gp-vkr{`hI`19XE9_@m{*|M-X78SEN zlqZlSA)3NRFLr4yXN;)Y{OKA>u^#4j98Je}{>+!X&fktE62g1&U#t%k=m5^QR|G~p zvj5e&vJba)K-8V@9yR?0W{1FcMbDcY+H&L0)J&K7#QnF{l>)45nEubDwGge?hi%!0 zK5U}ulb|8r&Z-ab;bSa)#9DwKA6j$;*WjI5^Ou_5V?^OEfqSj%<@lKB2=LMV=6ing z_el_KUbd5wKO`G;;Hx|`#*{`lo3|ER9iE?D4!NRfZ#9bfE zgn&cP-J5aPEdRgIr#Z0*udZ^>A5+!TZ{zcZH6zRhvn2#lL!jz_+8?fHtQ_OJ`5 zgJ*)fYFX^Vp^<5DllSj^AbidY0oa$!3-g#*?mu)gkVehKYM|^=lkA+-wa>1^GDQB0 zOI$LK91$AjS@6GQ2zWK6XykxrZ}o8++a>Y+$fu0?Zabnx$|20Ak~mDGeIK)o{6xL; z{~fjl2xt5SsuMgeM91_6)dPAYF7OWulp)U8gCP|6ZJb8Nx2h1MmvH~RsR0t&V`9*g zPt%}wnDYVGe2vo6oK7)qsSN}Vv;i5ZG;)Wb{uRdyeV-ZMI zff%iSI9m65%KdHmG4J;&syU6AqLhR0Dm$F2D-Df(<*r^w{rM>MzRXBs6!a4nBjeqB z)8Ly>j)%IZ$fk-#@e^3I$UJENuXMv1NEXA_Z$tRbq9rNsE*n1ah;B{D6V)%r=G zQgNWHihBrI{lXye2sys*^xCWORI-JZDB=V)>-9*3Y0|Gg}X_zuOe zE}ix7XSTgSbx!>^oKZ*U1QAH8V~x93%xk((Lf`R9Hl<6Y0=yD~hIw{;+ z%Q@C%FqqQ!%wxbKAO7#d!H;SRiq-f-6#GgD@6dU5RF=K(#vv|;V5j@hC%S3K3~v_G z7g20XI#eHBc>(Y%p5E^Z1hycL`0pX|p;0zTXL@DHLr@1+str6J?)2#}`vS?M826cI z$3^Zd)grs;y(?>b=xGOY7)p}!PBIeSbufZ+HO8{IDy3I&v|8Y*&)0I5m4QhCY&{zS z`&@%B2QG_4bcxMA#Qk3HY&{?Z#s)7(N2J7~(icAe7*~Y5yU1)7w_$gG$%C>9WW{W8 zph0t+Bd#rKr{1yWocBYj@t$X&6F1Sz&MAP&DR@SH#u}=d4ORlTSFNY7)eb8=M?Q9j ztH?%ffdy)44;RNJGs4R|_Yh+9lP>Lq-Ukmi$&OlM-aP^kg1h5xGDpLw zbTn{L-(11hB{(E>PtuBl?@fo3P!Z)hHpOa-%b|Neo5d zr?3UG&U@`17>Y4%*}up&dlc?IkXN{=(Qj%rwex{xJ+q~`n~F4`+#a{?mQbR)hsAci zJ@^HXzGPkaLr=W9-KA{yse{Mw%}IxFniRplL7(8#`hltn_pfRYG!?(2_0r}|#og7? zf^jOafg)4Oug&t&KEx?F-k|s zzzYgUxwrbEfMk0q)Uj$H|s}60fA#O3w{2^ha_=Wk8b^|FdpCU(z1(~m=uMtmB z`*oZIq1!$L(<8(p?IY%!ucpbJ-fj)o*s%h{7fS$q=WZXe1v}}W%74*n+L5&>x^c`b znkH2av~!etzgNOS#-!dX3% z2C)LByN;M0Ke8(wv1np-PM-~qSs1C6V_DoQ{X0AYAW{H?wb(=qgt<-!4wX-Gu3Km- z(VXx0e~bc&FOVHot*lVhy2pl>T`nBU6vGB~>Bk;*052kmDst_;28xgA<)*Q`9$h^< z_0dX7h zPNtY&ztau=-AVBPz7=B*J(m$dz2Z) zNn>q>FpY_$#f{Ed0P>>kn|f+bDJkJ1yzooJW6?u!qji=v016-kT7-SzcI&)v#H%s;JM`reRwa9=i`4JMy))hJh$tIv zJ+rshFF^n3h&+^kTaXlgvjW<)5QUxixPuo1;Jd7ph=mtUCNXZ_jeBdEJL=tx79V}w zkAf2ObA|MEWY)c}7WVf{FW))VaXN6);L*p^Z$Rd9a?Nn4K6b7dr}c>XbuCqnWt`<8m`{>_qhTwS>PnLIKaG z3tz7h5;ot2H00VN_@n2nq*tl49R0t7065N{Py)?Og|9YV`diEoX6D zlWbjW8V3XOKV^mwq~MLd*V7U)wq4<{m*Q5DlXj-_k>eu}s}~8XcYkbMnmKa?KYcK2 z-PQ4bW?;hqP>gt2yXP*gh5m_M)w1vkx@#AQP4m|xF*DviJoqXUb8g7O`}%9?_s_0d zaZ7E#@fTTBpe-dP?t&_@&dEn-edfg_rZxKIw>0bzDEUnNzWr6ZSTz?_(m# z!k9RZRZK9cXU1&n4@0V^LROo>WC*SfIfRTp^yA+xYPG=iPMOg>Ay@o1fytP12)DSz zp6{o*-k~89a*3<4Lqev}Q!A_C6)v>PdI`o6y~sC<43m;<0i!T8JSw7=s)h*fK$NO@WNo8gugx-ZAWU zlc^m|nX2&JDzD-C@BO#h@DjlcuH;hnjkz+9`(T}h@^+dOfc{c5IM#6)8D^_v>Hppg zBcMwGG~R+B==4YQWV^@b724u&^^E$F|Es?6`+o{gQoHbNroLI+U{BNmhMnpK;xkDLP30q!N-7y2 z-(TBJ@cudlDER`opW25~Rwodrkpz1wGEOm-)-VWlwuTE*H65y`YaLS$!rWR!4_R7& z^2a7Z;++WBq2oj;{r9M2hqD0LDv2DL4<<3Y3CSiAY(PsipruUEOFLOo#A9d{cgQ5K z*2axZat!Z*oPRg5`#TZA4oRnJaS`S^;}CpzP+X#kjSF88vpwYPjmzPH4lwB5Mvq1- zPVF)CW|WY5PIvJq3Zs%taIPRhQu<8Tj$kecI8>r3nz6}<;b_n4J>S~1EpYs3ozBC5 zh-xj3(fV*KP0J1Mmxox5K*K3T-;T8cCmGgZjN#Hi#a2x{MN14!kN+F@g3u$0`ozVR z{h88e)O#XhJ&KF-D>{-V;zx!QP6u##|{*{w~*!@LHiRHD0bg3YKf&~yUIWT9ZmN)GkbNjgPEcdH z#w+~#TDT+wCVurc_1z8p(4@bon9#R=}@~OHXiE@&m**kt~O3UpnmSrbJ^%oUz8jwQseK<@@(4wh2>}<+0rwgMEhUq)MlT;qnZV9M#9HknoE*rHe zt{Hug(RiX8jtJN6o2f=Q$1;51)d7+rFy76kJ|CnbkJ*$o*4e{Eh4dUEA%h zNOVuzNP$$Yd^Tw9{-G({;i@JZor1nEn@Nf)1_esLT!4w0` z{;!kNCA|(2Gi{)>wr|fZBd@E{#45pBFYhXPI%&7x0sJ$0$Sow)QzYWyB+UX6t9IY} zYO2pO=ZJYRWWIm3itP=+94cDuxGJ$;^D%XwfMP{eE#Sa|>lEtGOutZW@f4W)hS|i* z1Q}tUTk5kyzki4q+~GFxVp$7J0HA^s&X!s+;e=Z1DcMg5Z=a9lbwd8}>dcBdjo~y; zf&I~t*F6$p20E$y&+J)5F*NFalGBfSo1q;?)5%M{z@&*UUGw45npeY7_hn|>P4KNX zK$Fzbs-Sd*tocQ+MUTQ&gGwWOlcZ-ohpQVuHsa*vD!S&1Jt~?yQ#$TmZazb93TX33 zjG8WWeX@s-d#hV-%K}0iA~b;XVUt_iytTL}W16j-ZR#V1l+YD&of=-GLEECDFtymS zB;?s?-s!c%CjXQUiI;J@wA)CeYI?U}tAe**r(lsXm15az)%G8|bb~=2F{3oJ`g~3NJS>L(R^>U||)0>%>P1pGsH)gS( zl%s1FJz56cVK(YE7V~)>pw$SP4qx5a-+h=s3+P4I&pIYtlMnh;HrFN52vxZM*n?r~#fafhh9&$4&R`yQWKESz$VOIZ9eRt1V5ZW`d(LNB3MJ=lFTd#{y|#6)9RY=!tWSwwD-BOy+8BT4b=ULcZ2#z5Cyv=* zSctnX9uN;Ag}#o)D_p-q${2csH5KL_+)6FFWd1}J zdC`~ouE^x8D+$fM;cS?vo>Eb-IJa|mfZiy`yiKxqPDYulrQjMGC1r`VgQyO~y9~t7 zJhc`D%9gIqx5`q#Bp(P+o>zgbphM`%r2f!%_XV&dtW`zpzF)QQD`zT1H0M-j%dKKi zs$0fg_&U79v#l}Fw8y!}Ju1HHk?_OJ@}q*&^H@D6xyD9&8+BAPwk9ZtkQRZ{QH~ygRta`-@IDR%CnH(qw&or%jwW@8Lkp(sedUVnq-Z3bB2U zsdfrYNuEe(Ak&Ude>3~_^O5*+n^oSMeeseEjWpK=PyG`A8!|%M44EXdDsX*X-P7#GeP_9bJ zH4$eHjmi@uD5kAW|2IqRiO}3ImTJ&}Izgsw#_J>Yp$Au4b zw=F;$q@HPRp10WWJ$5y(`NU{(u}l;}0`|d!$i19xZw3Y0Yh`JwjMMR~AyHFzLxn4Q zQ63G30o~&sO&@HYLP&X#4oTMt_W#?-z}Ujer@WaI^WQTE!b<+69@CJybSO!(gLf2B zuV7{hmySnHGAqbW8-8}&D=>=eYx6qq?=yD|@Y$H$O=Fg*S-Uibsw#YiRX?nkw6vPY zs15uT7#U<`s!AoVF{EbZJJyc9Mc6nA`OKJCf-)36qRPAS#;4^2{`QK~QMb7B57!AU z3&@)~HaSImDVM0}C-x;vvL5{MDkXOl?K0|J7<6$?x1lhE`Gh^jdMn*adMc+k-jFj! zsHUWR?H)Qi7c%Lbxj*5z*)$81-IDdwh<+W;QzZ ze&e^l)}hA}{2OaNV*HoE6_3$}9-*2^5yTB8x^M9?*~&mNmbul}zoVL}e9e#L1<)5a zxWB&KVt+3nJ&O^1s)xRA{JXP(VX`)_1aI;$iV*a$rVny;HLzBdH;Y^0v%*7!5ee&f zRB<_Rja?)SQlCZ~)^zSOAE$0Jfmj&wjv5z!(0oT7*PR%$zcIjFbnEibj4)@#u98X7mz1RG(;ZtoFlMtNlC8?B zN@OosBc#l`>~d75=2R!i%`0uFHRIFc{gs{Oaduh_4@WW9$3H#X-@swwei5JCgxgTw zel~eFGBY_cHba^*r=OY3D5WR9R!8UVH;AfZRqXvu&D|uZ@D;ens|z%eW0uIS2FI$I zl8l-|APG?NbZHFA0kS458`$h;3dt&RUq#A{fr%Jf5| zrE^i3^@^!~VP=8&ho>(M58Z1}8pW}WH1Rlpi~|MO>Ewo`Jh{wcdtLtDm)O_PoquZY zn|gM!Ims*(TRBbg7b%xq6)6zPQ4ZEwYrcc8W8la~JRd~r>byk%U{`VDo5adZmemh) zSxlE+BIu&+(D1>joby&?SBI1OlKT=P$f<#()YegqHPU#5nvXPVJfbJIc4o>&*vV%r zze+L!5;Vo(5dK8jU6--NWkHvfRjV-Rw&@gaKr)dEtu` zka(>kxp$RmxOqe95QG$d02<|GXYbew${{TNdjbdtR`{w+zgl7EVr68gd9veF$SB)V zrg@1UG;FTo!99<_$R`xEU6;&UE8aJ}$CEO~q0tf?j-JL;UGZ)hE@hCw;Uy9*pU85E z?~tXql|HKJCJfNND5`h zbZW@6AFVSUHR9?xL!-RsUmtQE2qDWq`ZL*GX z@GYSC5cQCi0i(nqzM63tS`{2i%%VkgIn~Bqwl&jsO6HX>&b5w1S(?unn@uC znCRi&Bn_|KM{iZPRUY9d=En(I6^4d1F1UU7%x;^`krFLnJ|{cY^(VW%Oe#NX6ni)| zPdl&kEhB!FXCNyvS!dJL=JZ*hZT=9fd(nEpbye0*-q-W*uM2PKNBsI`i(qe#hPi+0 zI#L@B7goJ7$QQX`pj@~nkI|c9=^6V@JZmnYKy+mzXNk&3t*LpE&kq>kx$;XnXDkqo zC2Ti?HA&Cd&s$GiN9-GA{p3uHfd^=<2O{jn;o;DU@PUPaUl1uK_mpRQ%SL7-v4iF*lIv66$oE>Zk!q<81Uk(7)0}f=(y$ zdP>L%DHZ4v7$1KTtz0j@a5BIm0V9rQv>y6m+iYHe?d6ry*NJ>~HZV=**PPK(fsXOa z1}3s$uo|Q_A?72C%yc7!H|r z-`n2-y~#3khloC$IujV1z}FbYa*7^)2`Xp#%-!iKN#mwbLJa%BhU~zGmy#2O7ZBj- zZ?6YYfo^*+2~3b~T~n^N={FV5z2K1>>00>{q2f~8r#fN6tYsQ2K{CPr$=ivS3b?@R zkQJZurb`uPM}BbepG1q-YWileBUvdi34Ocf z{*`tDt4-gfgJn`8InEPR=RY0nFlFTNtIjgk@IF>s1|?rtH*=MJ-;%B=2xPbCaIx~G zsua=gmMF|`t|=biH!!6kYTuD^ZC#(!hEI(P(f5WAxjCkuQ8v0n$5UH}*1A($cS-;B z5MH~&&7l}smspSVkY~Vth*0%$E}LGfcd^ZJB)j`#-{>I?F=7$&5QCn@ivNL0y%>bO zo#eD7$MZ?^Lvou}Cl95Tos7GauG?)laZkQkn?B^|wqDx@|3on*7%}i#U}k^q8gH#k zFa7XcO)49|*Ne0*m71cp53cMID z3i*Y0mW0j86ez4U)$SXWD_A6oS0#PdG@@#4##V@;{Pe68RmOlp-Fh%|qTM9f2s~iw zOisgisb9Ny!5;0A;0f2k8aJgq8*9JUxG#xW!dvCMYf53#rawf-t5MYsDb%vawI7nl zP&a-t^s6qfF3{87lro8V&+hq+l3nP3+@DfjxqI5oCj-5)joOAY4c>SMB%i|}$g+}| z0~a>l@~dz-jnIn5TAzK*uhYgcRI{=O2q5ST&FQOJqWV!&MG;ssFV2`d1Ra|Ix*=Vg zwzf2ZsA;;CS2n&bmuj{9(Y*Gi?8kaORPb#JUy_>0wp0TO9In8#?UK)%GXzhRf*U@i zNw1>vojNrQqgHMtFoz3j{REUlEd>M7UoMmXB&=hc!R_ieDPO$P!QVUJ{f7v3sN;HZ z)0AdIW!Z~;a66KLq!rAiU{%%=^5WeBg+KBBrkOmvzhF@(Oc15T1#x0y5&NFf>wMGq9Qzh{-K7U+EG;^1T>4ep3{Jz$s39w_D%4^o z89Lb>nH>*7TzW5};jWRcAWYad{F9uH)O{L0Riew=$S{80&+Js|2iQuqXT0XP{ik)s z)2ad9lf+ISUL1kw_tN$mnp5w z-YU=O>sDH{)^ESwqI^+DOuykX)QIOKX>_%6Ut6QY{0vem91nzj^oB<}`q|js=`BR% zGg8okobBe45WQ*a(7YIV@&zgx&eAF}J{oDw&$~w$5eR#8B0E^E;EXQbU3V~n`#-=< z+L`GY`}ED1|N2b;^^I|AWN~w$oL_EyVUHLS2;9i2b*GAntJvR=a^en=B4+-;*i{|A z?sxKfc1uU(ic9iQwh1xI&8W%R1&>a9q{oR8gmGbX5)~PTssEQ9ca|v=wp5!AMVDXm zygQqHNU$!Geqe3sV*3wQh>nSUl_&BV1j@y)(Q^581y>E$lvd>fo!iyKze1be8fLDQ z`N~IKSzJEYK%j4q@{Bue##VJW1{j3S&#Bi+Bmr3hr?gxGtF#x)QwSdFyGN@6l<3lD z6>dE9pGue@9O9q`y#I~2x3z2|{d1V=FMf!Q$3|7&LPnbil_-VJxiRS1-I@1hUm+dO z&Dn1KR_(nIdLqN|%~U}CrlJkTy)&b&$Ys`RC4rg6`3l-qEJND;y* zXbXEF`Ym6-IPZ+_A+Sm+Nbk{s`JZr*rC=ksyiah^9p1y7Zh3c%^J@4UoO6%+NW(EX z6z{j7^M1vdiSETMPRQtEwcx*l_TPGDt{elGYWtfqSCWSxUt4=qP zSb_3GgcKCxYMg@7QIdK7t>k8ey7~{*$d4Lw^vEq(Cwg+}So`gtmMyrRDILTx%_&j8j6;he2jXh|25im0^pBODHkeiiQXgR0QWB$^B5J(ox2do-S?*uj;!h z{VFtmv2_GptR@QDl3ZorpxX+;S(M7Z?3PGD_W_Tl&oshI0a9V^6OviZJ-C8lxv@U4 z(2-6jFgpnT5ol}99!|DSrsQ1B?RnpLwYCrZrsJ#Ps~ys#a(EYNfkg1_dz`BK>ZJZt zur_N|v!beEH9y@bD5iYXJIA2PO|v$3PDZcB0(c|PQ|z(g{nP8;@uNQ zlC=CqEB|SmJFJlO#F7ZbwA@`f9d5uUGAh)w=aVNx&hA^48gJgbDoD2U4|(=i z!{#F~%F!@+$jW(>

M+l4Bivf-bO)=Z@6P{W|qATXO~<1vRM;%JGH+YX8V9N1z(Q z@l7L_j?`hl@-HHUdo4^Cbke=djMhci9nhd zo6c?dbmy;mG92rC@niow&y9|RM@FM-X2JdkkbWKo=wIjVNHv!f`A>OF!TL-C}m!>1HwXG&6Q z2JkB7AbTDmkp-iIr03uNe(`)%L^y1C19H*@{Gm{r3iwpeA_In(w|$*Kz5{p;0g>D4S^fD76Xub3EeH90GO zHOlriLFun5f5XI>;IKCo z+4N(4H5Hb4C7%-xlST$YnveAK!tKBSJIJxM=(>+gNB|NQ({2)5qb!_w5 z1I_aqMdw>DYDFz8CajFx$Rk~9yQ3E@ja@GJgjR)Kjw$LF&DMcH)sA#|$7i;+Y<#r+ zxEsmhZQn3RJ^$jn-pM;!3Bl*D4;^ufM+Qx5JEDHQa|{E12rx;Qk}9>|S($GhY*2X8 zLIV7r-#T>xq<4aj@Dc|7FvY=K*3%W7CCcj*L>=G(CHfD}%`!bX3eQM!-fWqasn~xJ zWJ1>NN!U@!?`wL$yMO`OR1kVE-Gs!qNIox6N)>$1R#To7*Z5H>qse6kEh!IZGh@;sdIL=b~WPZz&SJ>7InZ2lR&->^ZizG*M>EG{UO z?c0$OYb3vemY}HRntr_0q5j>_j;R2)lpd}$c&A;7T4;GO^3t6jIJDK9n+^TCN_JwS z(9tVqDv;52jiJRziR4f&d_|l#;w8el@|mudl4Y`chEwdcO>zl9sKRY1jMUGtfZhwj zv|xtw^%2|p?V`GS;V<)~M!G$^+ZA9)iQ1obY&n_-sNZ9Pzz}_6NcpHclM`K^=s_V~!dmy__T`E7d1Lgz`BZLS4LQiXOWs zTNeJo{!#Is4BO%+VN$r`n+=o{*&=SV9Ops3-=6^eHaS{1zG5+Er)7sf#~8s`HxGJ) zWdo2n$`+F$dgs&RqSK-uLL(_e4-`|lS&mF6MlUXf_~E!ROM>39{3yxS6oh7IP7ta> z+iM`B9Fd%o)|ok!VL2C1mEkK_wpKFQclD?3i(zAIMRV>m|K%{8*axO)puB?-Gfp$V zRQh*F-PWxj){}!&jT^NKwNN0fo-~H+!$V!2*3atO63NZy$gxd1mgz+Yl(_$6;x&}k zN3cT>Mw_w9C&{8BLLmO5D2~!?2yQG?x}t*@B49(&n2PH@F@rY$F{1YH4ir+x=2AjT zbXVbb4Junvn_*+BY3m^ixU_w?Y1}A~w^GKsk_6{fx8J2~d+Q9_6n9aCTxjT=QO}nJ z3}WU5Sy``CY3~h*#_x>)#qFx!pOTVA^XP?P-O0qS^c=D7u~oZ!jxN>m?fC7sCb;qn zTigWaGP<6N_fr=1r;Xcpo#CEcWU26Fc>j~GQ3j{;=4#Ey);5zTna$-bSbUqJT~ST5 z?*-t|%LApqdl*w($fvk-)@FJvZ6xTX6)kyp_5YT%!k3@}$U-z83PW`Tvmw{ zW`_fd4maxcPL(ve7vxm6?#AqI`!QhjkzbxzN zS*-LnoE%M>h0#)pM%?ul4VMYRlWyiC9M6SADO;_Lt z?=O#rdx`7bcSAnf2XBEkY|tTN3*?5M9@L1NYY3OdvrRr%j$P*IgUw227G$nb>17$6 zF`U+KV}qe>Mm5G3&OuM3L-;@-v1469bSCw)w51m-)P(st2Wg(uDa(u;o=nM@5BM{h z)f;EoyQ4Ny~S13~9+#(sH&r1BI#jbwg?M zPT0P;ayJG~_NoR?kI!|Hw(olSpn=()p_N#UibW}D*#zX_cqe!Ts9#nc*~2kwvFPyy3ZV^HOsnkprsohQ|tdKmouqa@wlxR&>;Eh%D>@ zpW@b4df&%men;J`-$X06PjIph2Hf!n&kFsRv5S zwe+O0D;yL5CQ|yaFsy__ubB`#kB~&&b`x3QKiyS`sB)Y~YxTaqV_yuh@E(VLv41h! z@9=@4dml+wZhuDZMg}QUbl~n!E(iv04EH|p==7{@o5?wL&7;hG((zNPcx$tASshC6 z$hfma^W9Jo&0{3Axf`GTvEsY*UoPDTr=aQ^mC+4$s#$#O6oS73ND6|AzD8&ss|pNq zQrH_ly`Uvy`jnI>9%+KaGk~xroSC+d>w(0H*AF&gMW)pMlu4Q6O6B4ae8#%GN5Qf| z*t1fpAAVvEn`yQutf6O2<&Nns(KL)tALdvSm^%(~-~+536a0At=2HHk8W#&|L20{B zcA^KC{xQQ1DEWa=uTF8$3+NkDc?1X}h0nsBhboTiH+?w`av!xKWbzAL2Q^q%j~^xs zfhgKJJ+~GPb0BhDUiOWx4HiZMt5+%$!s!{p^3cNEzhgcSYCoOyKax zM$W1j0hq)KoJgS)!wc6~+jkU-Qa8>P2szNBdL`uviCz`mg!O*TGRx9qTP-#bS%rAm!7>f8YX8I{%5MP=#aC*Ll|Axt>0?E>zla;tTIrM^bB?TFNEgL za79Fglpa~uvrC*Vq{^hxXP>O<_V($cmQwq4Pz~s)X6as}i$f5_?xZJ}FGQ#GEKh+k z+PSQG@uxv>+yM4x@49qau%lR2zJ&_7FBOwlqSkEt5}gWidwWSF=w>cZ-*)vrmbfkx znSqCN0S{ti^^3(teHu>Yo3s)Bh?GA64Vm!U$e@({4dWV}+<5)*O4H00mUG-<_K{rC z!|AcUyJk!qofN~!n~zVuq@&0jHrX9!-{~;(V&2k?T>NdaFtYje6u5E5-uB)0 z$#4*Q^tyG}nqXl?V;srm^N!SF%w5p^0Ei@yC*>(%i=5wh)6gh^|fU->I6WiwS+RZxzs1LHB%>Hbs`ApC86?^?pkU z3?1gw0-v8V{_5WA(`LX4ZfIh#LHs@}zFcQ_=-Yjf6OOnX)|1Fjlu(C9uq=`Gk3LQp z>C+CgJ0!xgeC1j7bqZvG_dGz~aEqXv0Io(=k!Jo#;7SlAKKERibZ*w2Hb(2WCb61o zgHfP1I4g%zob<|)`5TyaMNs3l%;w3bRjju(6u)X?+4BJjaYOI0rFUlVc892p|K$UDYtRYqAM2`yc8x-^l^{wymTb>+3~Kc z!+i3Ig)BBxsAm-|CrEh+r@nTUwvFa?0a;p7J^8)2FW8u4nkGVa`Fh*rBlbhmVHWL;bo+aXScjyQ}9~F8#i;nwHYsAg>grCQMrJOe~m`{hX)ZOU<6&_h= zuJ+Bg(!22tK>plb(?iRd&TP7Oo`cu}2kYz6Zl6MXL7VMg0y4qD*u^4L0_&ZTzSmdtt_z5IqqWbO5k7O(O_cnVWK1o;#?$~S@n+wYI z_$}KHDf#9QTQhkeO%Ft;E+nkIAes}xXEENs#1eEH4C)AThDk z$4NCdo%VhS3vMV%5lz^2f#7uH+8odC-#cmdjdAwGT-G@hVEaI|2;-EQiN~vHhe1== zEPDR8O=b0*oO`c`Y^%{)ioQ{!?jOE=j?8feAnj>j7CO~PBa8Q90uW0w{VnObz!NQ~ zBtqGqQMBv#?f-n!GHi1+T;B2U3>_4NRt>mE(ZPkjw@aQCg^9%@bRiPNYP>GmeYfYb z*;z<8szU9oYp{|v(+jUUk*{sS7j3v-j^x!BVt6x+3)xooP8M(3EA^FBV_bWZry41&JZO-)W%a^L-PR}*~U>Pv>J7bIXM zY*rNnX03y|kU`OkjD-$Jp;@Vu?c+3;pd5$wPXF8uOw==3MA3~FI~)rSqR6qaFHR?ozi7Zo-=8ib4I%wP%3h+Q;Ed4!`XrY~8cK3?lw(2u7#O7@ zk{k9ywmbMCtZ#N-X%?_XqwK6Gy9)`bIJI>Z{hNeOu0oBqRig|60d z=2mu+fC4~2b#G24K9K*EpLpqib^f3>+*Qi;#-{m`TCEeB_@ulRW0&!oPlV$eM-E{K zyq4~(SnTd~&uw-E87JceC8&R%;>msBvDAhMtS)ZvIfSX;)BL&2u(zZ< zG-jvC385${cMhwO0_3km-)Y(5_DpP-^(XxsPDZ z=E~G}q`=CtguebeMwSU<7NmaiuANW9D5y2fVpG@6zOn1R-WiCy!;DZ;c874MTAdb_ zY%*GZT0pI^Hryi>{=y$&1kKIHNQN6BWAWaH$$?dTjtqW*VkA*1_F)$3kr436S&{QO zy&_P)Z#OA#uXT^ikDI?QJ7M|w{qa=4PLTOFYN_y9vHDN}IZ zH*O8?5!@@Gdvx&tO|+SfWcHgOa5Q)v2(V9T6nuX!UP;&GHGij6=%>>3#)G!=o|fqf zIdYy15cXC-SQed8O0*7*@t>^`O}l0jD9bLAthcb(TxFu_EP^Z+2~~iq2Q{Sk+=5iE zAYjVrv~WV->2mg*22Y>N-y}@-8`^`WS~%2mpQwt+Op4p*J(^SleG@-F`+2;hl^MDf zf{X=bFz?Wr%$u|JU9$G#bk+b;N=>h*cf`!_jC&>9y$8cI8O*MNxw5vk@h_=?HOXl? zoJCh&e|wLS*x>=(kyNM6;`)_gD^~pfX64~tf&*jS2WcSC7LX-fBDZ7!5VUg!-J1C9 z9K4gnh3o*@#Pka@M2OO>rL5WCsay%Ty3!qCympVNAKBRN=U&4m=dy4A76L|+2LC2D zmh;p6&}Lwl({P(s%e42^*tJU&`b+F^zH9b0X*X{|H~<#=YHT3-3Q%-AE8HVKS_yr_ zh1-rOAoBRv5t4>wc&%|*Z@;8LQK|+2x)?_OYbsUOR11Un-f9&(Z(K)>Q{YwS8S32PBjf zL`mtAMp76PX`}{_Qd()G%X#<{c_^TC-p6A^4Elv!;LtGl7o+6cMP^nB9gbKRG7hT)1lDL zaA6WBc~q$WtPQfn)2Dr7*o;XgzH@J#>$!uU$ijmDmT^s6aLX`#@bI`Dlb}|!Svk(* zdly$<3ErrVkb7e$=Ro6(E(PKCA&Pj${?RL9n|_$TMT6_*S&ADHH&M1oRk`TzpZvt@ zH|u;Zp`436qz3jqN{AjFq(28{06@;ep643m75aBmm87heR>QZ#<0E|}2BMk;bueG= zeJ}4dNen^az8luUrD?4@YcQc9FSxpQGA5Q+b>y&HeR;KUYu~%-++F5tyyrNM9{A@6 z(1yr@-+9hsS^#qH6jGF7UUp~h%xR-Bx6w(H>2JaocbFP{1n1ogqJ8{g3Ufv#+gc)S zG_U0jO*-)oE?g>)@j3%evx2SBK_p^I5ceL8i@iYFeT_`b_VwA!m+zBbW3Iw{NyVle zKf7ma|EeMZ1cEDw{rt~Z`}sVeUC0i{R_dF3C9!8ctdv&o^Wx}81X`MUscV7rULDIs z1ah2{xQK>4=Gw|vTt#1!3ojd|_Y4}f1m`gcuIyNDd^u?8Kpy3XIe<*l8itkE)x+*I zb#=g~$-(1#(dw-aCC`VP-As5JA7SU&>fZ0dWF_kB+Gd7#?KCmU(3{wui5QF!Hcs|X z8A@jY!Sg)j9nh}^o>vy-tjT`c=(L~+0^$ zUb-a|R=dU>~GIFWs-{K4?AR7z4WMY`+>2|)BuddY@WB|FQm3M1SdF2wp5BO>GC zUq8sY1z2-it(nxFZRihFYVI&>{p-1C%^HbiAMGxI$qab)7S-mwL7$zmpj+LBDa&sa z2v8|3KuGQRda>;fw$^$8(g-mF@0kB>IqlYEKqyr;m3sG>^Cbp1QJ`o8x=i3X+2Hp| zRo`jGMF`4gRy1}AVtFxvBEBWLcoQ0MIvsKknl*GGh;WSrYx?$WI%Rh3F{};$`@dbP zU=}hhZ5Fj00q)~%S0}6}xKD1mHve9^HJyU>bev$g>oY@HKV;d+v0r(KzPncfwjqUd znj7ku;52@wZ7PxJBHp?9zv^0aAb*7_kNntDL_?Em(~Oj&ojV8lezow_CGy}1i@z7@ z{wzWWQK8g>R&37Bm@y@rco>g(4Kb3>Tb8bbt#AbA8C0~bv>5SVS|;dL{K@uwjZ&Jk zduEE-fF=_f`V2s=o|>li0LT=?LV% zO<7=-;Wp$ah!yC@DY;MbwRwD?nTEr4n_fY@2FiXUXxMpe;?0&N_KTK z?2<&xEh(-D&IHGA;N+ydv?GJ@wLNdM#MjMSkX)4hiGL7e*W;u zJDwxXF|5QaYcsjnmb8seWehuQtmCgcS9!*6*aOOvW>PdBsj@=FJyZ3wv>-%}@*x)p z&t9lr$z3X3urOWr?0Y|2ZI!&lBN%7Ib0X?2cxawb2_7T0K>_-{^%l^us-69I(72Du zN4Pn@O?8*}dM2&+Il5t%Kd;ipAPa7MmV3}huJk+q=e6SlGgo3nZ=wQiC1z~p2Zad* zEf3>9lntr5$(r;zX^F1A`^KfC6Wn)fsn#lQ-;@hU5p3Sl!8O$v6kgwYi{9>$vV5jE z=JMBYY6N{q(z5lJYb?5<`q=^;$+%L_Y|W?xH16;FAgMN%r|qpW5Y;eTfJ|z)QfkLE zxmEIcZl;~OUf%Zc9&&!gIxY|qDTZ9Rr2~meZr(9n+7MOWrrnGno0$!^-JaL;$CH}x z+7>4i~3)vXvCqzWR z?_&aQ+~4w%Ot8Nt-rROmwfT$ts7)4!U#QJ10Sy_LdeGz&3~u?;txosnw83Ojsr@);MkFyPg1oXVAfUZ7+pB9SP!o#}ta z>Nyt`+;mILNDZpGD7gns$Uexizs66SbA&U&=MxqeuIQgVdNmD1+6~&8xD41#a(m)v z>o+Hf+G6jPG6o0Z1p%gw-Zvpc#q9a_-!QhV3ToKz8DopAhW-1x7KC%-&B4@k@5BZR zCC{pVH3k7OVXyh4w8eN;|2~3jccZ1i>P_@G#)7FPB3M>JRXIgTdd4P=ogS>Zia%_j zxTV!eV9g>acjO}80^1`E`9#q!{-4p7jmPv>LTVAc~0KhSDIYFMOQylr{Es-{@1le z1nZ&rD@N~Bd=R!vS9(TlH8-cecqIT`I3zn&H2?~ubS3Ef{a=ITWq=mlaKyKwNtxIr z3`k|3aU5V(sff$y2DGscyi?`qE{D9n`zat!zVBpuok!HX5ZT6K+MEaUnT zf$tt3xPQR8C1ej{48QTAoLS@6)pLP}E#{}X<3u_UqLkJzts=j1=Cj1C(|RN?ymytu zB(3M(z4B{$i~#s4;5GBt?UR?)Ynm*V&oM=tI(fPq>bzU0C~FjHiEst|H<>4 zt`|E}11$Oyt$#_2q3L03^eb(0hGS+SG|6Onmv+zBaWr%=hrcg75BcfP9$d04#{Y}A%@D1S2gILkXgS3o;AKM;P295y(OJ?O2x1F zwaqbDrR(jv`4p}~x81o_b0A4wz#UjJ3vt6_@%C#-F%`35KpZ?-+{&e=RIp=D; zA>J7wp0+yCnTYl+iN8^jZ0BxdeWb$j2t5NEV6JW*!p*d^y&sd?&8nTPz((|Sq=_^(&FvB{qz%`gqE z#uJ7MBB3*9aNYqzFW>AmV%O$(-ru@P3y~PesLC!|+mJHzE-KVq>kZT`+^K@s7-qyE zdq1}H9>adkp{9h)6j2Gw62m>#u1t5ao|E9Jo6L`Eh&L<2*a5ZX7pI11#YcKPLK^c5 zhr0*-;#?l){?PM+yukozchOmEX4CLpJZ?Qc(J*+%eAV7r|1{Stix6=^yWo%d7g)D+ zSq7~l-E(Vv`?8J```qG>#DpEsdBJwAN!o{ZM#SGGOE&7;kShQs{&MW6Zh~igpZ)jv ziKYS^XG3D2&0Pphd_EA7D5MP4g@AHisg}xp7OCCHg}wVM1w;YBH32ZA--$+B$am1? zq36vb;>jB-NiZ~?QWvVFk+^LR$47`ni|C(y-t6VwGPf>Vjp47j*%}atQ%Y@c>Ca1w zm!6}yq1_d!uh~8-}5fKef zgDjm(zn-F=f05_C1kZdp-HO2u@A~krK#QTfUesFLx0*LBr+f@JYrMvUNkYM)YalUX zzH_@;g5a%0bUyfZ06?mF3T0hoIWRjEx+36`xRfs{66M`x(xlaxZ<`@CFv1?aq1C8B zR!tm1Dw-7`{TpN@MG#ey7h>6s$zV8ZZxyld`Az*aPXoq34Hf^I{`S&Elr*WB!$osB zR1Lv$Y3;4#y>7<#&5PDu&zu$ao)e)%qow@USIW~oJ=>; zAEsX#&)^cZR;lQ;$3=7^DYv7eHqO@c>pyA3D2Q$|)WHq`9rS&9*RdYKow+I(OL<$?+oVGZ!05j$p*1(xz}% zc5UFzF9zErPna*J#k?lLYCX|%_~DoB)$@YJwd#U7@6EOCh3-s9y2!*lghw)Ja zP;>9Q=$uQ~kt}P3cL$;8EAJM_gjOqYwOhEe=G^{u&%li-Jp8_1c2K>QGPI%DuFRRn ziM>!KW{8r0rStK$x;c0il7=j!SNa0`G|k9S%WN5KOfCA4fjSjvRa$@b^9#A7t+W)(e}=A`rTKEQzj$;a^$x$W z>fd(4M2xCBUxwYfODu^2MT$PaUTb=O>~G$OJd4Qlg7TJ&Ll#_Ln9>owcxnIW`0J%g zp3#4Xr^jJjq_gd!+ESA~DrU7wLn(w6RC;bb`EH=v0oZplqU)EjxyW2L701mU;7{vDmP+&gZwqEx3KBZ(bqGV82ZbOa}CSjQ5ApWcXS7|GO z_0Jl3ChQ@61ao<&WGepFx`EPGDsV|GL?e*WqAJPdRI#EM`NyZ$FK^36tQ6LHy&O=y z{|vT97m(!GO30<|ft`>4GB5K_`$nBKU`qTeKN-x zgRhcNN5Uq@tCe^<62?9Im+dVj>j?jH;Mt+yr?CKbTaIQ*1VJrNyxRB2o9n z;#b5l993r5l7D+oxt{`G2m5lH_3N`o!wW$G;hbdof+dfTd1@0j^h|K#%JnL>GvonJ z%`2{GE9S)7dx_8oBWnC}sk3S~zGml>6Q?boAO!9W7s5XH!R%gH+vs=R(1kzX=)lOw z%ux&uxh~oIU$%B9>cu+#hh}|lUtz5m4_Q+<8XLQv=v?}sk1D;&3wsG7@McuGVL>+$ zg6BXIV(UWo zjS%6|Iq^3fFa_dng&xf}cBtA;(_y#QU*e+>HKI%_!@@bK=6|-ntZ0O8ZDsGs&r~JI z*IdNG{7S++!JD9afyO?)$9OF>*+=2oUGbn7=-(4?x(tM9d;SeyOO*6UF^pBnl?}KW zs46H(^hbCSmBGjQqLn$52uv6A6#^6+L74{9^N&0)L31 zI_9I--ivE*#-b;!MBCU9rv2a`lY8$zJ=nkXGmWYWE&N`kxNo#j?ma*1U@nh|L~1DoA{4eeXu&BB=9xc%r zqGmiFPLug^AUV8E?Vb^_*uOB&G0_P7O?`H=v*v>)b2IDl2^PWe=DzYRt$5`MC1loK zMHe+{pLha~yM(d#NYEK`@yc^PvAClDy5oT9^D9b&IQvox1z%2U`W+J?Ld6%#?VzzN zz5inYo*IlQpX-28f!Z{llrE*JHj)E+KVTrY)ZGFlYkM!osqJ%)dj1pF``pk=#Lv;U zVHTC`xNi;GH8O9HM^5qrC}H}LY()~^qaPYL&=woeWE$A1nwh|RO_DrhjZ+vlCN?t^ zNzXQv{xeAr)0xa0(W|$G84-0GiM?$D@*Z0WzOtE5fV#)wEBg6in55fg?-G`??x7#* z##Oy!`BRkOJ7XRw!v|HDYf9n-iG14M{SS9`WCMuxut5<8JD*Yvcmphpg$TFBJkbn4 z&s=xnDlquvY+z58OB)M7wD9umjBa*?UBSSxNHYnw)z$1Dr3mo1!A#adbzS5BA!Y1!Qm0Pfb?+7HU()-Q#;(c$VueG}a3Ol0wLt1{4bx{o5_=;e_{BL(EMdMiIjn0Q`Ur+VA zw($5Vd8@YA-48)i5X8ZCGRlNSiPEb9|FkxD_vyzGW7;E{;pXj>y`_r_3EYc3_^Ej6 zL5*ELl!x48e?|gZ#rAhQ^+B4l(4}|dEpE5pt!tsaXSlS@YL4ZJ8M_alO8#Rf86*>i zSZN%8v<+^ryQEuQ5nQ8ROA<2V-_S%JShEsLT}$bv0Fc=L+sx2ckpR9b#E9y7sQE%M z6y~aCe|e)nIhQB_i1*;6V((5)3pv?rdXL2 z&@+#>W+bmN+C}KFnmoVcg$h+nVbaQ{4qh{n3S5;eNcaz)LPeVeC*176slie0uP#MO#Yq9`*rzR+r!3d7v7UyTjY&P%(FP#)Ym4(X8Ukp}9G%2Y0IC*rg`h44~Uf)T#^R>~!*rLQlm-;#itbZ~s~R1KiJ{PerY*S;(_O<9ev) zE9V+I8MPe}(}o|rQ4G8%MZPJJE{h_8uT$&Im&T+SoY~|XRnMXw?D~bk{OQWZ7+_fs z4bmd;W-6d7x;~nAu>as{i$3vOSr1M$%LBYYk_Ihwv!hl>CoKDJ{8J|H&(2C0i{_G0 zes24mxRejYc^b(>cpCn`xD<`|+0SiZ*{FV?cPJZ=Fb#qAFoe%vz`2t9PJN3u`MU_% zfIYEJoQdn;|9WxU`FPYRiLmwB<(a+8ZnlGkS?i3M<$CiQ*xfe!;c>xXc}?9Y_tm9c zP@H&Ycj~X?o9JMh)C;7Cb_SwmJ7BWFXK|t|Ro?&YDg^5>Wu+uSiIU8!z0ITguwZrS zTHp8?J+9e0$!lkS=r}>Hybo6HiIesnav#Q=8WfZ-D@nXNQk}toYp`wbPaBUPkx5n$ zL|U-eWJ|1o@z9rv`Mtj@NbD2sOyZ}RPhtM;us)NDHKnM5p4=LrMmxR9q+yp!?eO=b ziOnf?P~n%hhY5Phhpb`Ll&8c2N|BW(-s(p{ z0vP|pXwk!dzA{K1s)ujE8!v`hv+3!P(@62y_cCP+X6DqeSZceg!>>A~io(V#>!qlo zFgCV5pw;y&BK;ZPC1|Cb3TeA6ta{JGzg^ODXv5BVv|ZBw4V1}&DnBiVdgw|)1V3)< z{}04=<%c`NrbL2rEnj4_57K6SvDfg1R1LTS_f=TPc~<%I)!h4b-QFCaC?G!r?+iq*`!E+K`smx$HXlycfqBHfIxSC^-3Y0kLct8Si$ID)@jY zc^Eq%tkez!e3@!axjaOd)q__{=gQT8^{|WiK@+?sCGNm9)!@1|Jx$4K)?C9=4j|H7 z-Hsf30o$tdXg!_apSu0=g=*#{bySwEglkE%WzuxZ56UGJNN}Kr$MUnHCp{O~QK9WN z2@~#)SdZv^j*kV)@10K~Gd0az9WT;=hDN^6mfeCI`(l&ls}OVX{~UcCQ+K~IMT!N? z`9@h8lU7kX{e#VPhq;ed12O=B6M_OkAMRh>frBBAz5&8NzU!lp{v^+4w#~aIo5f?)HXlSV~~06lMGFcX6(zY zfSCL>K+k(V%u+!^s}4n|$@0IP8}<>2vQdWlV;_I5NGlP$W?BdUr9Aue_L1=6R;ht^ zLyGqn03W4jSJthw;^tAXRL`#QoTs0%uHxM7Y^_u8Hf!VW-8<3zMu|WEcpY1W6N&ct zDevOjc{hP7&C$;sZZw`WyD0N`Gv52%y9vAVUiY(v=YV;oaG^4iwqvyk1)P<^@t8$D-~qWN7~r&am2ORXGQF&fLmZb(~384)8Qlb5nC;7C`GgwOBu}&c4SI(=0Y(OGi+F@Jt)oOe8RC5k&dTV`=NryWM zE&RX>SOK}Yzf3SpbycvkL}W!FROCP*;r7kqDbCgFky|622rxAj_~`KDe1GdV z-kGl(vDhK^3LR;~wc$5=oNW0CDx4qHQ+F1c)IXAIJwE!mpC3@YfO^|>4BIQYaevA! zFOC1em|ZCJIGZfVf|_5TyFrYS^yPdc1a3;d(82y3++3`|Oayx%K4E(2Dt-<%yic}v zhssMLHkG|I5Ut~?AF{@n<|Jz*jjct9riavR5sKn0g z>&78G(NgLBy_~Q>5`MBA(=DJ>s?ewX+pw-5H;2$wz@_nNFK;0y9(E1e>ot)w@zI~f z&uFTH-!G{@wlzU3w)heeDG?uS=@EJibr&SNva@S!(%?L{sMxqU%|B#IcJaV^B*5f9bq%qCm6JAv#^(j?eKG&4$xYS zS#TI;mxYO?)0tawXjQ{Jqhycg4S4tyJ`k-q_C0R|mLr5jS>^_{;Mq|+<({jMD6bIk zWgFoE+d4#kiNXQh^STL!n5s zsZ7Ly0EEILv^%g>|C8SiFn$R@Q_tn|@D^*w$}NKC{fT@go7oSXLUgDdM)56%TEKKU?#i}-BAf2W^{7$zCssPOI-Va_ekU(exWchLgSIdFHg7SX);CJ*Sa`b*%x!r-+=0~D|`NYrqKbV@W&PK z)As9G^A%G79HO+u9zUZ5*{ZebnDE8ncfD~>YH0PaH`dnZlc-sQY{BXRtNpGkfZdiN z?p9pCuSVSRjs_*aqfVA1dH*rmpP+eE%TMUrHRB<+3yzPVxC*G_Y(J8w{Z%!MMXYb- zQWu*2&%XCgmuiv0<=ZLQ(e7L&ueLE@LexNE>(ay|E;v?sEZU?n$FTnElN&f#a>DrZ zklx9E?J9bapyVUDlP2Ye8ZUhlIzJOyAaaW84eDQNyTRN>Dx#e6ZYj(TzOsf4PK5XbpG?@q{1u z`&O2a!HKfOD@mH{2vcborc2QxEOuz({5ReWu{^?ai2$p=c@tz>4w6-nErCW?;dt4t zWOCn)tlH0{5&K5g;@kY-N6TQUSF;YZN!DpHSE_5)$0>hfsDmLav$%`Yb3GocOggFY zw{OVQnzn3j`TE&xBsoPg#gBOReC>zRnvr@uUfO%HB8_2tcBk<_TPy&-WV6~QaK3jk z7}h6PGXo{ar;uY#Wv^5@3pdJl3~98N^1ZYn&Fbze>(AQD|p_=~JBXet7$f}LaKQhxrD~`|eG*v{ zvd>6L*#R+O6u>=;^p&PZb_*4i-_onA7>eGOZKb$z;}OVkN)B9Nh zVfsGDYdnosTCm;5@YLN@Sn!Mb%jx+NW+b}V|FMW9c*!)-1og`o#82N81m6ZpuZscS z32W71lA%=3fQU-*UTIV~h#lmD)WMko&frRlnvu)|*Rl5E?+)cWt4@&c4A2zb^5#3a z>Yq9uI3m;cHHN^xa9Er0&NEM%zYk|C1Etg5Ha@{GRQ<(0VQyZO+%q#qf|uJ$!!5RM zw=gKnVWJU}@|1cz=1KZL^ji+pKabuEU_zeK_P1AHbAV<)6!Q9@jyHA;HDv9USU1Y7QTGr0Z^TyQ8y!zzsVPX5j@O-^ zI;R~Ic)#)bW#956p;Nka#QHRC(Cqz~&(G!FTCSdJHv$j4v$-p>+F06*Hx2iv$j(P1qJNnr-U6l zst){nI7gT;ks*8IA;asyi0JYV;v*d=8MiVR9$Y4qzWUymO`|tDzN6;C^!FQyPx_3+ zbB8~Z@ZY50@_?Cya%sN+)t^7IUAWg-50y|0M8q3!)ZoK{xf=S*PzoWAYUnlS?5Kee z@+|NHg4$&W7|d3Z3za>Ewr)m(!|p-u9=*jP2+DG2~J;KZ@47r@6#d!zDZY%9#454Z|^9fn6raZCheoisC;R0 zNdUOChIcjBLDG!kA@SZyn;F{OgNP}I zaR>Cl>x{>2L0-ALEB&LkA<7AWJobP$kM$4Tkx6=#K{I`$b4z_^-=z1NXim5JH6X)7 z$Q|mC7!(73^fgWYY5U6ab}*QHG;m~+wiwVhCZ^a-M(mLRA)Eaeq`L|c17Lpv_M6i1 z`X7PB6U60bLY!yo0%w`!RRyxnu}7@w`c=(b)2vRuQh`$`P{OTAo#@yYgC_)kcZivbd08T8eV-RhtkiYFU%?{Hi$D{$Ujw{d^&SbxAD-&(LQQRwdUfzEC{TfS=MLOu1oIoriPhyBC{<;=@d{#r@1_oMnX0MUf$dmJUC zGR+B)jMX!#W)0p~XNohlo7<99)qSU*97F8_74Qlbo|7w(h(vawe15xX9tN+Qe>Bn+ z?Fla&b&E}k#!dS!SKD(Yf0d;&;E9D2}7SLJ8E{I4FOX#of%C+2if>s-24gI5g4e+BPUn6%V0NoulR=dA} z0=1Wq`x8H0uSMizq>HjajXcrN-eQQvmoxpvKndtyH!*0<(0ar<+G`%v=cwUs8{K=6 zoc3zgH+$R&ns>>t{#tu64a>DW^6v(#dO@US42)voM%4kYOqZb0tW+fc$QZmWkRsZN z3_z_hP(`w#gh?hAiXf)xz1s6;JNM+`(bvumB>~|ZTgoZu#2&m|Q)blt@(>{#Bn}tls&KX8eHd`j$C+D`E66t=*QTt91#624Z2s|*B^UL?_@yxQ|0~F!i8K> z>WYi^zblfl)V3V|!~zE!!oZua_&;}m`txysEp%xmK2Xmxq_^>!(NTa~vV-+L(=E{k z8D$4xQ3NR`t2M5kmfzcxGspSMKv-YXFThO1l2@3+vBSlpkwz_$^2Ctoh{)T?!}ooW zr2Aq?8qf{x9j#ftKLLyXv_I46LU(Ob=dYn>7c_+3SRDryv(E3b=I<_l`cdBhwPFxi z7{k={n|JfQZzmRaV1arAFaY#9U@l#LYJF#|ri`QU#1XD559UpBR`dvY^PqVRDNqsZ zQ2E*i==qnnRB8F+bCF=o757Q2wRmNH;67gwX@h}EPpFKg(TwDQHiO|pz9M5ES?D7ft;ot%yw!a8W{kp#NK7%+Lm5UW6Dbm~fJ&9` ziOdz|F1?pM#mRztc4JHnW>-&@EeS5eJEoCVIzQ#TT-VR``bFF`5okF?l6X<-t#`KU zNN*)XNC0uy?Q=K}%f#_G&RA)tc{kTSo0@4zC`NnCEyYRoLbh|!^FQ<~4;~ok)n-7r zpBJ!Fgn!(ndGy=qw+;BSlhKHR;N) z?Cw$t8rPu>+$C_^=-hj8K9_MGb19W$fvPnc)r^%N)hFkXETEvP0Pgyl$+d65TcR7` z&f^-9VJ&2FLFfxG=D9}Z4;K2ck3dw+p$NZIdrsHhLJ+kW2aTMXr_@@rx+HClM{PWjcBG#iK)ibF|gt6q_+ZEocrc zcUbALcn*{zJ>=8KJzeS6pW_so6FpN~Yvp^%bH~9cM}&$KmIq0IkvuCsIC32iV z+|LYW^X$f+BffoY0q%43pf_l4_tAL#RqL~g_hJjxgxju-c`?}}akOBh$X6alCAt36!Z^BQ z#_Ukea{W*ZMVK_3y6QoonQ_2qy>U%+^R`d2H{V{Pnc2|CQiL%jH!$o$GU)LELVVWI zDC1Q{WWAUO7Yd_Tl625ha6(CM4Xev4OoItW&3>Oax$Ke#9SnkxcFLQGe?zaHwSicy zK}YL>Ic(a((TUThKw_KnGlG=kix!^xe>6%7P3PN3e>NDK6 ztgEu`t5UCCu_!Y#r)+^&PJO8qey75j)m+eEiebe5-McaBC9_^S0YgG z&84+zYs;Q{(Vmv3K5)*YjYHp)D&_?u+%PL5>p(Yt#LEBP9DCbu`0-*+jd#5-pVXYS zuJQjkqez+^Zd6WT_gyzdX~nhF$l8|$CUGLj?C-m>YQj8Hhko+NxV@#*i=qumOqQLW?jP=L5QANZPa$oFn-F;M! zxm%H5ZSW->%J_)S-oSVoW^9^72AIZ$0{^z6-zNe}tq3mIK+xOHMOCin4<0tdn0K#s zL?H8qCI!N37uM$S@0%ja9Zm&eP1M@?o14l%zSaNLtffQEdJm_M^a#85cxsJZTYr!| zYS;`52L%loe=UFZSAk9J;;q*Onzx=fkKB#<8uXR}H52vab1Nxq3-9Um)eb`7{IZP$ zuQ|)LST=g^XR=u4sUPLa*-JG1MS`{l#^Bx2Xd`Lfw5F2$rg7=PI%uR)IMf?;&eFaL9Y~!NHHWj+ z>zPG7L_1N5GZ{SWtS139=`8oj&r&JtwF=d0=It$q`aLa0)kiJv4p5gX7wBp)ga{D< zXxuEu-p`|tJ{;1b^S_fEp1G1XoI6)3Y0`jyGiYDzcN5hD0Oo7C$>|3LxPNW=WgH{?9e7?-u9R1nIMcnM(XLF_*;Q{j%280Frl}G%47R`dB&0BJo?Z;ESvVF$4RFL zdO4uIFu~O5I{&}ymn*{w%TY7x1kG~S%ae8l2J20L#oM0J4f)Ha8>%FDtq~`IhqR*6 zOMd5`7^-7xfgaF9CYBL12xySSxEvc!oy329kR@z}3ZAGrROUD^(P5(QVX>?pqHLa4 zgOu!j=PLu0h2NB)mT;!2yZEL(oD*#9YAl<5g(yh31 z=>nb}9EXM~EqcluvELL1iOd%o#`iauskZ!7JA zzu9nZah9auc=dD%eNM9C*tW`U`>R2m6g$rk3FhCAWk{JJQ4kj4yc_M%!@#3x5r1lAXy?3FvP3r|u+grCl6n zg9~WlUPYYJ-iv79Oxww8RtF<;=(2(T3osIgLGhgs7>}XFB_cs|+GXvO)LU|Uzw^uA zDd~Y*S>C8!B6S=-uU@L#PEcnFVeuXGP zSO#0Mk~iQdH&FoA3|%azpo`^w4%Kle&?^HJ`2gLI&o#_X&}N=wi0=RnMIl8^Vt+b= zuUh4nX`*2+S48O1fFvs#AT4VFE0cUc(KT1KBRSahuq%9^24*4CZ^TkFS{i~4q0Q7R zk^Phb}$eBd6v}!@bGJDJ&td@b!*!!F=VvdeMv3k zlF`hmMj2xB>QDc&5due!+){@K(%X0qnvhB|b+7ew0!Wrk+#a2LCkMXm|9TnR{+3g{6KCf+DI&H`bAE7{v8iCYS5)DD& zmI6#+68+Lh;HQTA@~F^dc_tnWo)mY5>@hOvd*9s~oN*}92>R_mbd(*d5UxHt;-eJ( zlKV?fI$A9kLi{BZ@pmoXB(n|kCfZ{@7s$y_md&Q>JpX?}4QutWg6j0*gI<;Bnar0f zW=iFPo!3)NqY5up^Koog<4qz5DSGPP12bS&NrwP)lU!kpkrstD@g|cZa<|9K?CTAa z`=5ZxO)4rZ$)u_^$nUAH;sxEQT{%!bwLD`V3b-S#P@oIE8&K(Zt7zpuGSQ2hGhR4B z)6bq8h^oJomLDZ$T*V*oAtNHfkmw;x{#=Tx>VKAN$pz+5O(smtF3(Hd-QfGd=HI4n z+-TIQQP7=U>f7_`>3Rllb_erKBay(JX_l0OL-&vupxQF3&O@j>b9fxEvlns%y1s7d zKxFPFhX%(Nc?FsR(?FIE_NR3eFv=5$Hfswj-z>h>iZl!MW?%AaCa9jfb&tj}6gD7m zX=@N4A+S5=I^*|Z64D0i+9aSISHD8|ua^(b%+Id?eJW?hf+p8b7Exv1zC12O31E`f z?OH*O!7t37{3jwyfkT_R4xEFUo;XNsP}4-_f##96&18UseF5g*A9VR~OCfxcLqm>v zd3RY@U#46nPsjg$2^KjVHQ)!lYpUGgvY)_uPPxqUVO32Sr>!Dj7I+Vd=Eeq^hl!4T z<0SgUP)Fo@wF2XRcK6U7=MihiF2z8IxsEjSwe$XsyQSEMcSxT2p+I~BFxOttKC^$W zc?~>S3<&413WV!iu5LB&E4AJ$W?!Jo{g`$Ew5)3ZVSC*1qQ(P>^!6rV-DheH{pR<_TI=x zi9NaI@03li0wEZ_T8=b#Y9(dfVbTa{_CKJ(`N?SK+B$yM$7mCQe#TdqUh@vfF}XPP zJ-@%3y}~=(z56gNnvB3+#`aFv|MU94PW_AFlGvAOg^@1vL{Ux^+Jn(7xn zP^s}#lDe>5>_Bi-AhHzDz{u_kbeXshl$CI?X_SPUaD}%yQoo@xxD3i^B^i%gu6ti) zEYp*6aQJUv{4?eu7;(#APXURVqhV=g=$#XKyquQ&`+@s`5hkhke< z`EhEzBGb21+C!#Y4od$h&?>|PGj#VVe%+wVxUjlM(A?JihlR@ zSzCOA#ZPR^d~#v9>UDOjHOo+p`NLa^kH-j-AZ5Hm>-_f?11b{#%3_S>{Bto%$w0KK z^zP-pJK*eRM7(dtfJ4=<3=UOw3}d#WLsy(*X!#a-K90)|-+|JL6VrOC96b7!{kcf68IKDo5jR z7bJs5dRng%g-%A zNfBwg+*($V%Eff&**EayEB0@UpYqkdJ&N5~e-}LQ+Q7>HWW1|8euMP?Qz$=mXH593 z6i#N=l^sH-JyT-oD`j%Ynb=qEs143Am8 z*;WCd8WE(H(k+2%qMh4h&%CA;xxiP|Sdy6s8PMCKshp*jGU`*DgQ@_9H|(h{{nN~d zz!aicK8{)E&yAooftwAq{{ldb)PM%49RAHggosgH=Zgs>?NSM@FesZdRsIhW^Dx|{ zD?yZ>73te@ptd+icb`@03@Q-5oL`Bl+zD+SdFB zd9%Ri;?X#`VdzEcB`OvnAQ?z`QFAC8ObH%=sjiz`lKQ$-<$jv<@YPNN>>@z4SyfB} zCmLdq!cY`p7R`QJU?dKNGo3qcL5J)=y`{s41zsS<7iEadb}?EKxmcOoFPBHFPF<$H zhPO>-UeViAz|PsUy}A55p0$f0PVBB}|8$7UgI7CL?JGksqXfaQFp>$E37(TVss_HJ z_ALhSWrzWXH(I*C^bAmmi1nPjm108%5m&a3hoGd*C6KS^M^24#BAPbBiaN%woT?lA z4@Bj+g_>%^%G-6YUdW(E@JV3WG?2zk_{?7Yn4rz@VLtfl&SEEAzkg?Mw+_E$1dYa?iAs zhc&pk=8i6rm{$%=^kcO|99su>?`L<`rGvK8;p_cvZHGbk!EWB+4~TH+T2{s9Ja~cN zgSp$(A+WmK4Wmy1*4`vg1pFA{4ovmVX&WnDz(^^KWjr8*WYOAH~OY09!GQq>;?^iY%EK?P7DJ=9RP4u_;n zZh+CXfYY;P_RE80m!weu5~c*k8)dtXDq|n@K>O#svh`u3^H)(UonK=;_~mF%go~_; z{R{wg;hjh$=5WJhT6C(bI;?g#sdEs)XpSfCa3+}t`XB$}H$6#Y+kP$^kqpB&c)0iN zmrnQTg~~0hxjsXbFAVNy`aBzge+Op<70FEiof+1!#NL)}2Q`~(!viGRMQw)PKz;aP zzS2p5x8b05F0OpFn|>j((no@~AG0zGGSDXFL_5W}Lcp30_&>tFJRa)p|G%bD zNUI`6wAi;I`!*G!Y-N|N>}8jIYtqe?LfLnv?AiBqN?FDlvTwy03`4fTFn;Gf)a`z5 zeSiO{$L%`rb6)3l&Uw9_3!*r&J}!7Oj6qwbdFdL^fj?>859BKUcYNdt{2N6J{a)~B z^+##@g~cur+T|11S?pgwM55VYL3!o7(I)!$A$S5=X~XriZKz%>5?C5H#J|^zIExgd z8ecSvJypD=Ku}4EvtoKyIIzQ4_{CupFyHY0lM(D9Z;(Ckm>zStMAU9jY}#CYh~f2! zLx^3gY`OSt%;5{UPVsYH|K|sLAEf@AhsrU{Z0~U6LG9beluQnn-Uu-QTbgU&d*_Kq zbYl+C6&jlYIHPd3Ga>O^xbq9PM;jGht0LQ`tiM?JuLEDa2$*yJKYqoe77y1ew!i4l zDFNLR)0wNTCD%kibsob&6*fEB!+`N@)xbS4j2GPH{R4gQyVDYAa1i89z8T#6MtB|c zc>K2_Jv{*uRg*;f8e6@m-G~6j=W3S)>Q|IBy2euDqaTHJc1t`Vzpw#o(Q#Ld-)A<4 zszennfvL+$GyP!*jPV;fR$W!C4wDSCVx0y?qi@A&#Rx_lo&Ueu{u?-;Qy zY0&%0y8G{Do|I?lsY0`0Y&?iniKmyZ0E~`*p+@$Ia9@!2yP}L&m!ahS_ZJpQFuo=J z5dIZ84`*8attFGW?>RGvv+ENcuL7f8Kz`lTLeLDi%&9w)$FcA1ysay2%!=hZkTKa_ zvGzd33NpL%voe2tOF`(eWM_o4Jb2wX+7_Q1%!#RLSTsZbs`&9U)BOCwcnO9vBn>jp z$H{hcVmzV(P@poivhL$*=eDN0C~QKdA16Z(8j@J)32qhUBscJ`81_;jmY_XRKp zL%OuZl^U{}6)0T_T6~|@7yPkYoyXErqq|x1*JnQTvUJymJ?cFlJ(3#M{?I9%cEAH7 zLjNYi{D?%~90c$J2g(NXBUoWZEmylR@tWw7p>XgdU&Vh3Xw3*h< z=7hds`rMc^)lIt*5?^D3dv_0Fn8_UJjhE%zm*gs=+sp$S0Zas63*F+xISaR+7};Eu z^TFQ*HY!LwDc*jvI9ZRz_Nm z!WoyOmy`XHPQ)V0L^ihanYOqG7VlZV4o4}IjgH!%&S&1 zl(YgSEjJYrk0BM16N_9@V`xhx{ZF!^Iv_$Om@QPqI0jj?z7uoWDo;#7iCYb+3KqRL z(`0}BKiY%;Gels5;d4T)ad095$rd~Z{@Xf^dFcWBa8%P)N+pvC%qh4J&<*W&FR(*g zJmr_wQ6}FUrGc#rS4*cNZyHnrJV&vxrtX88EzxjY7+0u`)~Z0e6rfiMVys9VJ1-jW zo@?PAGA!8C2vYO_=F;FRU5XknHet`>^lb?Dci>oGWsyy^xXeG_Dx%&ZvZcDqR#v0N zV}UKUpYh3fl;&BIlWUDz8G+#bfT3B`cu$x`<-%Lg_}G9in0d!$F;7I6JySil8n)g7 z9NZz0sXB$0Rnm2bZ|5QIG+MEwF5qcZ6_3L9(mhjSAxcD25q%!N3T7Q&)`1WFr-GKfQ}nuPYT+w94~2Auk$Q~T#7n@H z0eu6>dkfxL?75DkRuDoEDt**&$l!0k|4H3=L8Wmn&{gRxDSWdb^&$mNw*!?av46r1 zqyE|@ZeTN`B6zu|W(xmm`F~>ZM|OY?LVIbX{vl75Ip^%>HjAYYEAq&RWK;#bs=2tb z*=19-4nA=da6z4PI~M@t617DlAPxenO&un^_dqE2@T5-Cb)v5exKF3Dx1WFeX{qBS zndJ*EBCbgN$7Rpow+u3gH`h$%+9=bmp3Q9xb-eSXs|qDxa0zrZp`+pVn;A?E_ISg? zR^_$bBeHplWk5;_3`_G7NwpZ`YZhzx{3259qctGGcjp70@Te+8^)v0u97fH6UqiX^ zBEEF?%yAwF$(T)n#@?##kxpS|zfoS7phesR9RsRr&Miz~x1kg`gzO{#Xv5n6+3+)_4U&BwE)8!VO6x80dC$WYsG6tp$AL zsAYcVN@34SI`H=hAU3hcxi50-ddZw8Ue9%muJroqueFU6Rfv^<%=2I4R9){o4jFXn zvCaFT#OF5F0gY|)O49Y@0SH~+2wDhVN#k0V{*Sl>|IXGLb=NiLawx^1-MrsRk!vh! z#RD=0Oj|Y9oh_8#;^qDZJNvHC4ZzzhqaMHE`eKlI0+7jqfoN%b(U;Ffnjl_*y|Z$8 zx_TT770_!(iWB8=LjlEA+`2z_&ll8?7{pi%(Bvp+D~-N#))7eZ^bPwkDHo0%d=NeM zp=sl}Ot8d(&;Qpo2xv|9Gn(B(AbdetU(TdL?WKx)4Bco2Y;v}K*JnIW-IQrqruS|hwS}ep5Jyay0LMj@W z`Ih;s$G(Qa9iI`yYH-1gQ?}RL4_t}_e#1(-zpy-iv;NI0#v(}tXd$%oOQ6~e-cHuA zLsYB61K;!YotSUWt3U18h2easTn2XHC^yopQOkYmNpX&n#6PH$DPhP7tV4s3; zPt?YFjeMaRn`nMD>7x%L^x4t@PnI$2+l4#Z7-F zhw%3_NgB)=da&64jqq;x5CNLjs9o^skMr1F-wuB$g~vShl*kT|`_Uj>y_Q6p2&cE9 z_Cm|`)&qHII;0eZur8h;6YX{?86B;y^-FnG(5& z!qICX4p`KuyZ9Pw1~Rj&rRsrv;&iw);p$6A5bpoI6hVIzM9M*~ow2KQeU%z$-0xD_N^i31`ncX-iIk!FPY5`Zs01+9DTJ%uZd z`M_j-53t1OQ9sBQ`*>oX_})^Vy^OaQ+6_~)eqP3q7pZvakIgjP-OOY(dd6e^RYiei zV8p}K>voW~(h#-&M-PTY<&q)GA2f{LO(JR)?#N^ln+A0DxPrGqB^N0xn_N7jZAwpG z%~k*VGYHHqIWe%{P+)$ROBmjB#y$x^TfKy~T^!+0*xJu0aebl+*_Kb;Om(i_m-vf( zDvE)^g`PyCKV!?3RdHXh3BE$tYue~v6@$*gOLAuxWMt13&5i*MMSnQYL0R=gn)MApTD**wdUt70nCGMJ10Xod=^CDE06oqr;J>S? zGHP;=u6X;w-+U`6Zli&4CwzR)oQh@2gaV&G#N)+$x+#?`%mC79B!WRUvKmjEwtKSa z1#~ACX%-_EQ1E;v+XVuN^Nzs*p2GAe1xj~YG(u158mgWP)Gqz=@cO92Je@Jx7Uzv}^`T@z!_41oyin(Jkg>HI@V@}ov#7u;f0#08*U1b@xA z)9gi=!SC`sr`-;VB1X9el;dYp#gc*`FD`Oud++b^b`>okP0!G30j6wu?eS7ZTg%YE zEXG58j*_ME`sPQ^&IhBGWmKggQ25Uw{p*K9_S}lrVve4{j%hAX90P?YcHk}q6=g0?f;}HGz+0AIlct!FQb`9T{c#7o{kyTD4#UB;dBwU-hxtR( z26;CRSZCtSPze2{1L)l!^uj9jR`@L2S9t)g|)O8E~s@r3NALh`$CjIc*)GG z{UySK418Sh3)BPoimL1Ceg#SJ0Z}0+0qhaJcz!A3NpDF{IP8}b?0@G=RFS1;@N_34 z9A}p)*^P0(UMABiYfK5Nu^8B39hmZsHrJ(PM1hT~aIDXGE0s*~49)>x5T#ulgL5Rv6k z@8(Par7E4ujnTnqk3^r6yej8vlXaZ#lbrzj*J?s(ivw21_dQH!T0dDQydDViOI2OI z5O0vw=(0B1uj`3>zYDX%Qv|g#~PUXaoq4W5y;QMHxZ z1HSbH5+OY&yjHytr8al%aQ0#UbASkB4r&iyAD@Sc_R#nzU`^3jesA!nr88@{NP`PQxL5sz~Wl++??0Amkx^qeo@ zEa6O{1+94LBWh5$I_@fqglXhdiM0I4I%5%{tn9wfGz}aeCXk@2sj*g5o0)#m3CXr* zXKu>^jRqRqE|^)a{)bX#1jiK+W++DZ^m*N9kZL&=r)a_c(+=%Ft9l8> zkXEUji}B4hz~q5&MnAks3zS-I^ABVfNP&j6_@`>HExzf61KyI;Vau0qm~zFkrd*t? zHx9APKDzgO7xF7;xm7Vrk|GpF4s5G(WyGz5n!`yor7JtJH^G@3XVISzQ37<~5mQCd zH`>Hm+pADNz+=D8)jxYBw+7trYq^B9$}9@dCh?A;pgSB19=TOE#h;vwm{jtZ!M-Tf z!pK4${soL{ES#Aw{>^y#;nlEfvEUH_5!|nVzU4E`r_2O$E)gdhVCtn32>)o3BZx3( zqVhem$O3@q%4iieJB&^MEegEhXc>{ue4uvLT5#K+>No4ka={HbwCnS-ZKYED!$?j% z@xY^Wus#C@z{#}BU6png^Z^61b*w;T-3n>m(WjWrjSSzdW$^i4B;YlGd8=U*%|43F zxf-nL<#&{Wy*TCz-mDD-H`#YbwW%M=h5 z{&!8c|3*oUg|H#b7Rg0W(vPSIlEPKAmoGT8sp08|-wdWi1wn%Sl$SI?4B7J`XH0oJ z2}5P;J#&^6(&qBsQvH(@P<@6Dz96RKGnApCu5znzR4kcoN58-#tN>!0KpXZeWmheg z+c+7ot2L>(&bLMpU*Mx`>5Bsbz_?ZFN= z?SuxZ!}uI>A>2PAaNMmQkzQJZ3?#6S0pwIIeVj;_Y}-~^Z7-3CLT0WeZy0JCzQr4h;2T&YLryp}p7 zBKu+o>Pkziqr$^H1tl0Im8y!ys4;N@AUauNr^qIFE*tqLc>JBE_+ zTrRq}P?*lbSA0(xp(G#Dzi0);nPNF{AJH3Fai#)rZ$JF5F79D3*aYpb5TV65pnFM+ z^rje|QxW~u(G^r=GtS}C$-8tGXr*M>=7{kgIq?{^%<}p$*^BYVqW)_tllCB=CAwWN zQ06QfSriWbl3i*fdKfB^u5`T7;7pMMaG2pejc36(h!&uxn>TSjaj<34S~^G%t(=~t z#xk7tkh8~n6>)g!!bNak@UTaOKG^;Z$gflywfhxip|Fcb$+a?Ri23Ix7))SQS52;z zO8A}#efAJ{UOV&6zXtTnCPvOtIL6Oiocr>2ynI9eY+Y~g9+QS0>a#^}y{;Ux_*%wq z@^E3WWY-!1$a z&i+Ktby{BXTAc_*tO(iC&b36{rQ-&F!^V@VWQ(K1vKKK>hj~Ab>v4;_Gkxnn7@Z$; zY0i~<>NY?=y~G{nQJ*G5{1d6N1f0GXP&5-00fe&{qSVo7ut>;6QoUoabo+3!7$Gz> zFAJf<-&1BCZA|nFxW-~~#PaCm?2h&$n2rnL$a@ct?1vbNoyR$3ZaK%FqDx4L{l+3V zz{Vvr*gup}rqUTaB$NYjSbmJ<-#-A*s7udgjBL-iI3Q}0(;g5wbTyM`bSdz*N+FPd zwCfu4en^lG$kmGPHlX3<_)cRuDk9Q+z=w${1aig>+fxTN#tZv6{Syu142ma2woRm8 zeB@Tz{=LTfurCl>5t=yuT%{*1;{}XbkJ@UfEgU}&vL&~S=f4E+;y(;=bQf|csne0! zA^x5w@Qwkdf2(>)je!F>Bl8>bpgH1u={rz?0Dg!e@o_l#chU@!5>$&4Y3J`*Uk-#g zy%uyjZ@0kpoq}9YTfn);#wB?SS3IHEJ}`E}>?lpQ?ncYaL{@(_6$bw+54yBYqfVzp zr#ZX>eiswAX0p^9dc*t@bH9%E+PU0{LxqYck~ZdZR*~VJ<;0V-?`<9~oK~QN9yko} z7{R6VrtkZ+Xu?izT@Ty68@36M3yYk4F2^iQ`(Ivv%j{`qHrmehRt54NApes7qdLVq zg;CG~1n$J8Eo**&t>4vt8>VWGJB$A_#0xi(`m}>=LR*;&vORND zR$tO9q+gN7Gr|3HO_S#`P`zafiH)ZVSo5)V2qeq^PeT7vF~~j>3|N#R=CQ$B{kKt| za5A4wW6SS1_zOG~bsyH3Q`%hV-!tJ!HWz8-QcERFXB}xyTXc+zpZmmMHt3u2U1+k} z*G1|FUgX~~2L^i%)O9nkkNd-3;<%c?4YY0*ekVm8xG&Q8@f8cUY_${~Q1c0GfMNn% zJBB=i#p50!r2aZT4xNThlwq_~V)j{`gqpU`HPZ6DxhhM6H!WD)cA|{~*7OQ~_8{~p z;OM;u7>kp7qd5=kJDu4DZGAz_)grE@>&N2%H|jhA3V%$QZg5yq8 z)(x)yH0@WT44hs2>K;7f1pC96**=6 z#ftr8Vg^F$%g1p@0w#fCdWn#jKH63rlnT! z5@O9>PzZ!YdpL^gfYF05r7rB;^ZYd*1p1u00cysBt^qq`qC&Uv!j{=%yK0YiQMVvk z)x|%V++I4Mz^P#|bGrvxKmbqlUb{?%y-TRQcPG7`uD|^w4u;WxGP~x?Og3`<{noXk z9`FAuyr0)`2m|@r5QF_Le6@;p_KsdANq%JvzTUD6liUkr5Flg)=2}SMYW9#;k?V+38$enmMnLy*5%B#8ztJIikXrkYHP=H zj#O1CWwreMZB|laPnZOIH#jW-m8zkIIkUOQ@4NU5G5-A2X)+XQdThHb;V+k0%`J)2(g=7`x zP+aY^1u6NArU&Ix^7ZR~tjAL}gR&5PPor!6{pXNx3!a|pgF-5ogUGq(RiU{D)bNir zlfii0ZYK?X40}6W5?KfbjTLF^m+4s7ZIP^OP)FJDg|uoKsV_BJ3j*}<&Qr0FGuOJ{ zq78KR0d;rGH6(;S>3WAUDQUSc1#9}3`OJUzF<70wTp|oN3RSe1vrnWk{{2E(MAyGZ zHxkrND+=tv$ew9IxGy*Lq{gEzY{RDcKql`wQ}mo{CHPo`JgA`w?+dAUyXMN|{Ho82 zS|2#L0&1?Zpp@>;VQfQb>ibn-cCh4ggfO&lNv=pU4}HQgu35aGzxGA zp@gKTh@l@@W&{3&T@~@|iTq~Zr3Co!Y--Z3PMzPm4v_lwl266}Fn2qkoBh+th=gZE z4lj|b-_NcYM=QjBmX#Prlu!*pGNVS}6S1}x?f;R<_-Aq$TtiWW#Y$UM;{ii; zz4ZV@y!$^H|2J{j%A_)m>-ix!H>Su2PWf zlLvxL`=+X!E0ocnY!PPewm=_90q9A-yH(^RFT@#ptWz7Xp!obsWX9hSbB0&KP`9DG zPlmM&u7vK=juBl{ILcA0?|KB7g3jY2`x)P6iH+>C6C2RY+2_Z+b*n0 zxxs#n*XUU+-_xQ$pX&(Vx?AS67^Oy67NL%H2wyPUkWGjIYmuF2P%g6?X#~ozixFv_ z;3HUbuNS-QH7`QnRxGAD>Sg=(EtSwqrZU`{MSNx$dPHmcL>JXhQVh?jNuJ(_7n1g^ z{_9!2x;`n~b;MVqo*O1}oWu9_uXu;F!%%(#Bp^P=ge3IZqh(Wpljdg!9y`l4x3eAB z47Ub?Y~;4b@3I*9-~h_;m~r2&g19#Yie=FXdg=-$FU||AD~yLRf`QAaLar7woFTDc zbh=A-+CK4K8x2II=AT=`{8euU2cmB4BZ2-y57G7X6YhbK-0p&_Q>^ZYue`x{6{MQ# z&D}n3_CsQfxbY6ka{qYuL}j5I$v}kNu*G9KY_rRf>T|p}8#}3%ie$4^1O17?Pm7fd zraL?2H;MQ}3JQO^Ye2tKm-Pdo_L-y0%^|k2mYIAK-<>^SHG96l1i{8cb&07cv(IY3 zMyJYL=}i9BDutE`?Rz5bzTeT3c0kEEAF;SFP;+8w3#L@2=;Ug=MciLdB{ymTQ#R-G zEq(~O{yRi=thIqw7Mv1bQkqppr4#3Rd_poRmQN4VpYT;VRDHF=gpZeg>|G<#?iGg@pspkhR5um0myw&UGP|t=?7oMt6~u+NMJ=CpDJXog1enIkxcwt@GC~5O z97l9)g9@}2*W1L{_^_dX{!uNJPC>RB0kL_L z+O%d}961s!;06?@!4lktIn!AV>oshI!i&>qJ(tdZsNym*OZ*g^IV)#&=1*t!LzFuO z+AYA*hW+Lx&>Tq-qy(+se7=O%_GRPpOFzu>lr)l54C!fGl=X(FHiQXGVUd;vV=mDI zZT=P_zACdad%HSlNS`K{xlvUq^lpJr!nrTcj#o5w?K?+}TkQ}#fDMA9>33?fB-si*ho88eQx>7r_xO#|9Jx~V?t#qwYN^vJtJC-X_`(LQ zA=ccAnhNwXy5p^94Dr!capFLw*ZRsk_bLH|yCLp&G5Alw(YcCyK&Y!x(T-+)nop-7pBz5*l}&5(Yc^O- z*ySyZ#B}sdUGoAr^4?C4U!+Y7brh;8BsP}PkXrMWQkS$^)uYRgm-1r|#^0W!)BTS+ z9dxZA?^~oWg`-U>%`G35*tvdd?c1JIgXZbL^+n~u8Nl5yzx!71A&Ldw?f3VU!zQ$8 z2XxjQ!p$t!gy*C;M6q97(Z=sJ$(~uG@<=wj@S0F>c0ZC)2$#sciN*BBECPS?3LSvD zS9{5S$>AOS(Yw-vRhab0TA6JF?N(Kc*M{!%5776fH2OpI^Zl^QO6{uoYG6e6`{W1) z4+_P^n5NBP)^gW92OKi)45V z*b??-+x-f%tjJZ$#Hr|YTOR4X3j`&u+hqcoABLZ?@Sh)0jj*zw3bfS7TPD9S)$YMe zWNcML%={Gqym7-^2Tj05%JINTl{TQLZIE-0Zq>MbB=dfbyL0P+Abvq)%9qc5pz)~S z5f0x&ipybsNo>n@(2II}(UW!{*d2$@ERkZJk3g&i2~2MBvM!P@hNuRL1@9UqYWj1k zS687J%}qqCIg~WTvAYf1Iox16tEM0CD<(TLZq}586Dh`qGHnYH?u7HdKLHq2#jDt< ztCZPEeo-b;j+gg4XJ<;y7R(%ryohjrdS6ooG+&jI2ED>swJr7|>P_g<>KVPX^RRaJ z!i#fMtZNh{PKn%#PfJ^$qyRa+B_Lq%O+1&cDvov_`tGv0;-&>aL8YrBo%L$)h5MfG z6fd!KX{}5TNzms2wN$Pup{f31qv0`qSM0NOBPUW(SkGv|Rj+vW<@<|= z+(w>j>@(JV&;ckO_s1mANn7;7pmTv&IeXOpf-1CRq(EV{Fe3R%*rex#89d;$Q2 zR(QtZ9uPg-4BGN4aAItmtQO$hS8xN5K~w;&OmR$Dxl$sJ;R}|1o|6-j7R!02tvh`; zhjqkovg%a&@U{Gp4=D0Cjo2#)yk421JF!%sFFrUuugghsV|s5jesW3vbL9nx z+Y74k^?vc5o(Ob%JBZ7p7&Na0m5cOrpc6B}*WSE?5fS=s$THD@ZC{g&rkWZ|FUEPs);Fo|P zH$!sGe&H=iFY|?WyRYLDBNLWca#ffEPp^Oea}Vwzq6tZ@*|ezK%9-1UPoE7Cb^@j)h_(0tGuR9#jQ{|+Y!U&qi{CXTs%i? z#YWZ1r7Z#5@RBVr_Xx-huidou+m|x)of!k?Rvld~`P~W1{DEJCV(^Pp5TKw|JLCS*b!?us3 z$IvpoSQ*E2Usej)QnV#}RU;cSrY;o3F5RrJZju(KG3taLq~#d!y!T*@sYr#`!dGAZsMml>` z;EUwre{?f`b6OUj4e^XkbPB!~S~pzMk#wz^zI2oHT&zm?ht5|aUkLwX(Rsb)cH;^+vJ1L}HeMpc z4wa&Xt2{vS>o)`c5$k*T+s}Ux|0KnG+0s3xD1A4Y*G?H&1{u4+lm!x&=^JOd-6W_) zY?X*d1Vls(utNlyR;$hU1(6CeLMsK3Ld&G9lWEp{?{K5B8|u^=iU0!kaEnIK|RwNhw+RF3iN0W?L=t5DiY<@s4J`rY8iXf2QC6ZOP(?8+=A zJ+jBt0Wj3quLJbJ3y$ctu&DEStSV>$z(B^rhw8^Ea**!;@ziJASg89QlgRShC0F z+-ZO@=GhzqH8Zo^-C4y|a8Th({JV%Ypt*fmiGW=V zy4NvxmVEak^yspK+_we{8bR)x51CD&Ss(c&_l<(ClH7R~Xi%bn5!QO+ViC{iL_oyl zL-(^mwyxQ=<)tHZ(T_qp$Px6`Z9_HG!lKGE!MnO1_AvjY4@ zUqa`oSWR-wQjOw8@;PGD-Qr*Ay!B-u;i%h<(QQWlX7{6so%!)QlD3yN00C_z05imF zj0x1c#J+-G$`gHx%b{(u;+Yzhu)7$c$*S_FAb5xpfeqLhx?diZ1*27kYs5p+3*+X% z!(P9Q`+i?B%eQ{`ikfr9K>Si6cw^qB5XbtoX3Mp zFS!#oOPngt&Z{FAL=679UIMgKeol#koA#cAH!Y!!1WM5UUaDQKM_h|x-BxSlTr7xm z9KIXs7<(5&V*X*jod`y$U#VhHElvJ=$>Z2fm&0#%qnVQK0fvngsEJ&vN;G%LpH>@H zx0SpvZtdHwoU`wkSD9jNW4Q+-ga^zmLE$B)@61sT@#)wq>4RjDVn-2EnRP20B&C(( zZizJxek8(|{Ow{S@GEz&&Rz!0M$p0rMoe|fl;0Rl{*HwYy$N%=l|rJhb-{K6yTR`N z>Jk4OKk5Z6INjCP)Zh!H*EygHDZ*$7tzaswMHL&PIBJHR#*|<;@wOK*L#Sgh9}7lF@4f~ z9(uvPI$|xeR2N7G)pFow1>t^<~BU25Jn%4&F zp*Xzan+_79Q!d4+|3zNfXwBsQ)J68unt#G2huW5HJxc~{EB4j7R zi6V;1of0TuiO*qiOCBS>Z5~i}4|#-IBfwqgLuvg{$54bX^Nn2E=ZkFaLY%+2==^&^ zw+D}8fT-tw;XHB>Ij48XbREA+*AeYfK1QkLNb7mhw(|T@^V6<05+Ix9;CNW|zp!5>bO~#XE7hpxM1!WvcH`U$#)k*5e zjLvQ$QWffhSmc}{c*;g0g5)C8zt7d)(=C@nirZ+CFK;NkeiIu%b5fkaXpdL7q(yvI z+v$K^zaRY0VoYj)C{b}20_5c=3dhBdXTOg;7u%PYC^yP4GE_M|Tq$nhCWzizP}{3v z>rWr^H|54c6G&`6vUJLK4T3m^4X?CVD6NgyXFdQP!T#dWPfTNI$C zHw}{-Em};ZolXM;%b%A3s;D|LSvU++E=m8R?1C78;fhan4fDXKbxqxkU-lslQqV)l zFRfMJ@?Z-IaZ^>EZLeG)sf|?g@5?Vc;~>$stL=n#eD#)h9m!cr1?l|3@R{I)o8)5~ zsYs;(yku4zt)DQzrlbP>#!!U=-z3|n%NxXAa$yy^u~R<%Rdvgp)j)N=w^dHn;gkOP z^jUn|=-4sZu>a%Y3^r`d#@&bc@1V8dryPG0AZ}+=b)o4t+=p%6|Glfs!uZ^jz?`s5 zE5n9_NWf6bBR?bM#r^gKCTGD9&9NW7BtDc%91t5hh4IaH9J5YEhgt(k&z5{^nb2mH zTjZcMmV|wAyAm(^CRWk(3>B>QaPRqN{&yY=G;u^UdPo7=dBt)?5m!iMO9aj*P!mTi zZxMTj6^RfcYij=~a=>N>oAyIEn<;p+9&dPlG;-?B7aM-P#3Zn73uBB8ZQf|4zQ`0l zDSq{ovNWx*ZGyO8C7;o;6Kl}zgFpMrR<$m6WUEqofDy(|eaYQ*@f)MvPQ^JM07$%| zRnSSNfFm68uF zYUq2tww?eeQ5;XM%Ko6Ms^*gANG_q+H?l69hK<&;%MFvUOYjkE2G4 zP|(@&-9cWZIDP2dg>VeG@uOqH_CbzkrXlsfzti&V#plgHBEgQPCGMy+OOIwmm8kLB z8NPMt!~^&DwELIdn>emsT#GZ^upQTpZ1aPhfYyQJb)+V`S4J@OQMx=*Jn;ZV{#JtL zjJo!A?5x1nNL4f+YG=-@Q}iwk#1B(8Uonp7srN~^)6AW5eZD1%_Jre%n2KTK80O=o zWRtyCm-3a13htt~s}M~7H85IihqNS0ayD{e{3ts zcZF0}X=8H#RPiEksAX|Bc4l!gc43h|e!`C88W-iQ%ahc?2A9XFUu)!I4vrtJzVFy6 z5g~Uy4E|`(=tqZVoZl?W-_f${zNNFE0Y`5cx`~9FuA|S1Jml@@(Xj2szibj{ksS?# z;nWWfkM%MaQ-qgDt%$8>qutN**&JQ7S!z@;RzNx?J4Y5@7u=~c)Kc;IpF%4fS)K8u zuiuR%Q7n{~H)7it{OQ})=L~}PQC0?Sxv$jD?#Ah1%wPt>#MOLd8|U*#*D3863oq=Q zf6xd2J0EUOP@e?5i@1>T6vsGZ&?aX9_2g(n3!-VLreORCo94Vqf3Bax?{N$vijP>{Xl#3sS=LLyPajjtxfQt3Vk+>G zl6-ij85wK({vG<%9ylkRzna8{RQ|B0rmax$G)1xaZFJDN_O}CUv&u2)r;)ByaxWaE zi2uj>NmB4lL>FfTpGiWjoyY*l9^)+7kj@@@f+9ssXprI5C})@+Yhly%T2Ct-oV6RN zMi|g@LG*iN*wBO+fCd|$s*@mMdWw{A!g&kSo+W(6_p!q= zU+%9}=N+@OP0;(iK)?S%U%sM~pWI=tSTs>(?X>mH;XtlHWzpY#3wd`TIrsCV#0{o9 zHq8NWhR{lSy=Icvv=pc}z*YD{VwqcMWt|of@C(97JE~HK+wFTk-PTX>sMU@oJagtd zJ-0XiQgbtfV`bHW%Yrxe%54!{XSf1M6~1|_a2M(@r>;7&d#TT($-g~G6`9i3y0rWT zHPI-P1-9{h3gM~I+WQiDn}HM&eU6LY-r20CA=aM7wV@5P)>kK=(j{C^+Bp3+XC`?o z(z{4n^Kjh`bnG{Yp;^gTtibE7r|E7AwEW&~DM$_1lGdz@>Vxd47%U!^pK~fO-Vb?MICVKkVYO=ls z#<{(VAtCKI!`;bjp+|L;xMIa$u2lqGP@(+`hUqLl1(+z;;f}SnXJ6@S9u2xscWz!t z1YI~r<=U=LaamrX#h7_vIIs1hy;IEB=i>B8M|DHT_;s<2!4v&&-*qSvD9R;mItC?A z!8_l>T2>gHJ?H7PpxZUr#R=zfJzCM--bE-23eOzjuH8s>3bd>K8sm2%?gXi_{Nq7q z)T9ago!wR`=P1LR6obC=8Td+GA(YRkCV9>Iyue4aZf(Ar{xf`JJ{jS|T!3~Wux`?WQhv`EJg;Z_H$J%_eJQWXy;xD&SBaI~o#w1s^`+^K2rIUoE3Ge5bIV}*`w?Wz zxh5W9kz<5ZE+@9zMS6HXK?&fp6Z5Jy6ZzZCLfPJV*$RK*wvTQ{Gb?k*p+%OTa0>kK zct=@MXeB?d#G~wR2j@yZbE-*a=L}OFPSY_aM#`XD4zj`8j;y{sWg^_xXM7EpZ4rjU ztgxlGJadegeh3qklz0AWK%*e}^pzvHpy*g0ovFA9=V{P`4`WMp$5+d;Sy>!*HXdbI z+r!4IH#_Ym7H>c4rxA&JLD_7W6o~TgKB)EoF~QU2wEyeT|~ zEgGv@E`FBQxag0{gvVG*)jMfK_H;QLs=MZN#P-&GFAZu+-Ihak(nq?A*`g0r;>Hdp zyOIZU;Se%TdxTBB$D|gIZ^^A?0L@#I2L~G3dbVhaI9Q#->eteW}|lb|Zq7Al&l6D5<5>tY3fRerWj=1-q3*FZKf6s}x+U!CT z)KVNM!{%y9p_?nL!I?9)0}hXQum67eqF$jsof9(xxQyyKQcS(FUfI?LcQ0m zmpUD6&0`2MR@`a{i^Gu;$TjK*i^>KQIfpG5&y3=a52!kJdMSvj-eqQs9=xL%9-CZV zs2LN0V1^lpbJl;+PX$rA^0sMn$Vl>AhZ(+}cti5sd>X=?->Rek($1r4UL5%aCtR{D zNFm&&KLS*+msoN)XVzTHJ$kOXmb*oxxMGF)2fDP{fLzd!43si(hf9@Ch+ri0D~*br zAD^fG{fDvGs~S7Z_2JnqqBma^dSMNGL`qZ!O6`QHQ<4Ey{C?+JMe>29u?7Ftxryc^ zgnO%}5gu;N5kiYQYgLm72w6VysF5<`NyDB`rX_}|!UeUo+`cUt$21I?s{@3nB#+q# zt=Eq$lWaP*tWtiHF`H#z35}h`NzTCT z(Ff^Fw^cl|XLA1?{DkG~Dp zs8VpZT=a}wZkn6$86e^B#wXs$TQC5j>T#qY&-q1=^h!sx$_mxs%I1v>EZA1z1DW5)V&q0uCI+;I7Y~bqdeTp84`Lwlp?2W za-c6KI+T7jZa)#%Xt90AUA?73Sw^LV5hmDW^2C++3 zE~&EKIyiiRH7o$w&|nyg?4_JIBDa?kwFf!zo>4G}Ggr(U3pBMy9gtqOr7c*0a2Fuf z_Y^Mu4QpDe61%%q9)t*5j%CEvVCxSduJNi>pCfV;7t~J=ESwgewuUJ1zsse`$2ka- z%O||n3=FKEhI`7!-_{mm8)3uZ3Pr3^#;6AwONi^9+DZCb(nmoqmZG#)&9*sYFm=}l z*0OQSM|`X@r9aDgWhtgWFZp>Bo5{#DNS`9W zB;~daF8*^utWb~ZZJyoly)wl8AdDh5c_~Akd>v^-&m|Thojs51M9e+)s_)I5s&4J) z(Ro{?DER#LDA!jbOi{9$`ylb=(r=gXL;>p4i%0x5CET-~c?-@ye1y!AiO;gesi>9V zB^-}ei4%n&k*;*MqBd_i72$5}sZXk#sQa!C%n|5~L zK3L{Hr)JRq zgVUwB1@At>6^!%B_hr=Bsi5C39DIV?{vu*}PVkyl4aSwx7wZu35RyeIF4xuWZzC$KzTHDId!K@JUBKD?*J`x)aLj&UwiRKIoT?%g#b*2G(B z8?3N|Ad1e=o_<(m8G~9aWz5f!%ys}z?$gPAK%zZ1BH%^QBRJSGPsnA%d02p zV||Vs2Ro5!T(&|%J5tIyHFk~CtMSvShxC**cPC!=ew!19fT?1yZwE4|k;|`OtvCIz zbFPoDh7+QM>)W}S(DXNeJRWrzKTD68kL|%Blm~g+V*2OGezz8}95sdui9sVC_Zygq8x4i-DI4GgCAc}x= zBOxU*2BN?aA|)wENl6JqI{GR~iZnwhrGm6{DdJE=Dh-3c5E4U3$9#LhH_kco`_B2k z|G2!o;CY_C_u6aS>%Q;RP2HtlT^ZxcE3~zVm&@sr>{|mv5fZPnZH+oSHL*8jQTyGZ zg5hbfYP``cdc=nM_D=Cm!H&tcY+Li%D6T_76gjt8qJG<&PSrth+9-^x-Qvqz*hXwQ zh<`&U1hHcJ6i>f zuL-lJ_GHLJi1CE*17Xo${oQqCwq$?oM1TPEV{-eWXE9)$Br`BgR2inA3YTq_&2k$! zn`u`yPtDt%Ra7X^RYWq(RfSb)zJQI9b5zmJW`4qN&)KLDsFXH6HV#_KlY8_Tv5&Pan@co*gl=lQgMj@VBTKTLWvzx)n z=)$+je5+v#Ntx)TkjQGIShO%+pt+e@s@Y}x=eJjWdz-ixN)k=i@^&>+^20G+1&BC( zFXvhJ$&}TzSlTrKw@7hB+%zscn~m{)SuABxXaiMxpomI_QiaE~Ge=bbeT@aKNDA;s4b_V4-l*bU1%@lXU3=h_b6>Rc zk2c!100#k*X8*}ac)xo;yqdM9IrS|BIg2VGvxw_X+YnYDjZ*80`s}=Po}M?^hmjO? zyEw?)rq?jxo890Olp9^gs!LVHSQb}xq#^pc3y0rT9{XSm2X|2z$o-_1cX(ZT+Z=|& zx~msT!*p5=ot>9weHq%47^It>yC&9clTE&z-ap(Xkb$;Z{AAQhW8qMO9ON({gKC&1 zlCZ7{Nf4$0`qj?tOMhgWw|$~fY)%j(;e>2XGwnwku`)b&(eB2qMdNSfEHB^99N(5~ zG`6QR8eeqiuU}mxCVju%1Jua5wYS{`4z*aws z)%iL(9cAv<3?Q?oG<4Xs1L1pDA^IG$k8GUNCp<0JDyH-gQ-KAY@K%#glB!1S^Eiat zr41|teYK*BMDCMTt=xVr&i4)6jtFn`%NeI5aw#88n<1^dL(4CEQV8t9{zwF=;AFU! zj)CL5z-#Bhkaga?)(9%a)`evtFbz``TCQ5LYS*%s)6d46SCfBC7Poq_>4@}*Gw#+gG2>j@UG>o^IaeLC$ARCMjsZItY}x zbC^O0klZ1MZVO`H`1s~>j8kBCIti)@jpMqSM1cLA#9^d+S^P$J; zBaB05SgqKQ{pPfaNC0W%88VkaLXx{8b3wJcm5}D}8P$qGexL1}aEmwF`ooG&%~Wyo zosOM;N%LZ36O(a@a*Azj>GBtKZ}AUa0HJ zh_Gpw;0reDVAe{=Z3J4Nj zys_|T&Yt*TCNoPm1|RfjczDTNWgCVMG8*)_e>`W_-N&O@ohdN4^>de4-2B4t{`Hm3 z#2}0jjpMlIMpj*&z_WHu5I1?V)FXy>`2~XG`wt<2}70rjmRw1>0?B25!RJ52%@3d2p>{dz{m3*S92Jkm8s}w zyzf%0BhMBC1GxK+{Q}c|Q#8~HBZG{^F4rvPt{;%-pZ@s$L5P}tmDy%kES%(G+VXKOunIOI78HdLEq6dl-&U9JJHThm)=fg=}4S&|=7qv#ve$Ly%3TgmS z)(jk&Pb&P#+$|(k!POS#+TcFI-0Xde*!;q%rohWvwRZ5!+<3jjwZ*MGoE5HgJHBif zjqmbYEv+?Rkg^5!*4{$2=$KvT`K%GUw)uy}8Gsf~lGzt*I<*aa?w6+!>@w|fO)kHv zW)}6$v9rnA`hYkLPUT2NZT%Xn9MEu`K{}P`d5fd$snl*Emynve9IzAi_m+991P zn!xOv$lS8?Q~V)wU+*}}46~H>Gu#?xb|RJU;T&Oh|5~-kUFBh$CDG-J6xKi$ncro< z|E3mQY5iU`eCyR2{e5pLHPCd(_qnH$=_BH*?fn8wvK}D-a`GQOChG~-__ze{yx|oQ zAqrzi*KI6r-Sp{5E|d861Ep!6ym_9|&CDQe5zb5U^b=9hKrRLK+FyML13f{@MN(^K zNS5;-GWL-J<|=)vSP4M3rCE7`Xa$+dh6^kkd8KVRP$O?}l=T4;IzffnL%Ay58d0Lsiap&TV)4$y$&!X`C^53|cNEE?R4nuiT@a@?@8j z=(-1Z#7`~7-xFKvv%Qu)^u}0hxJ7t|iEEbm!c?m;=+L=DOr@lTTI5OI*_8>^R5A#M z#pnz3gR!c6SHj2}$bjA2n)2DJlZq5Rn3XL|o47m=EX5I7>4;xSD!UP>=dLU}Jr&)U z6X*7Hg-35&q;oj^R}uQ3+GwM zd-Ur=rb38P!(yqsB~&r7uAIoi*b*6rI99UKM~#sP zIBjN3wTTpuw2)UZ$4ri>}e2=7m{Gnr0+D!q z;`1NQZ)rz$op2go@^v&h7Ic04|JX9`Yt4(}R1<3DNcs^OzWUwdkyNe)U^>hSncK<} z&et@g5@m=fN2oL9X>-Sn@`i`3N6&sWQ%V_Z=*s|oxGvhMjHp(d0S)ZBfxHj#-)+GPWc_G8Bt zK~SVb&Y5QTD~8$-cWl|=2gX`*7oK!}qi<<3FKhC?4P1_Lt)tiOcDaYjPUrYWbPNE% z9enMWOk#md2e=~&QN|RaeH#zcJYgqLY)JEV6S?_&% ze8mWa6Gp3-R_m3lJmt4F>Z$%UWBMz!m~BD^uvC%Wx}eNxxso!d>Ma#^%QQM2f{Q8Z z3p|AEBG1;iSlOY>-ySS6pZYYB>U#6>`-rcX+;$^jsFMsD@9%EdK+8L>%3@$AcksQr zxGT=Xc$DB~TWGx%ji;Jf;*%h>ZXR(`3E0g>DNKlMi{t*{UsCGT9@f@CHi8XyCwp__ zTvzc%iYEl~T0CcvXo*2e0E)y{vJ6{wd?Wsv|0G}R|4SErX{^=l0w6#PfNfwSD$Xur zHZQ@DOH`-)z)%c7q=R$FndBc>oexUuGE(HmJq)+F7Tuygi%y;g(Qv|FKCYG~PhPnS z1>?8Sc%=$qw9t5sUw36096z=5(obY--q1gSfp_xFlbJU+@D`2WG0ch~GH7E?1XlEf zH*`C3CUD?RfCP{`Dbr4M;+>R#7?x;IRJI{z9fq~>NuV%1LMp*D#*SHgbm{y7F99f& zMlE~8&KLY4yKCb|LZKv0$Jh)&7A^~IIwAEu?LEh7t>Eh^+x0K4t}aqC+%wU3IO;0C zYBm~1`PI_je(=`%8?A2?iK*}U=+@%{D8F<;$Rm>_3yy+wN?JsQgUf$^c9#=^6Nw%SVf{KD7HZ9n=uaVz4{&EXjg3WEq z!PTo4XC4H=sy)iI(khR95j-F|f-zY^(`oE<5GV_c4U2(QhX&X!{*cAULi#S?L z%eb2{?ioMh`lv|s?9i3DVJ?P*+atbZNFnt6%G)ENyLTW1XDAa>DaT~%ZyO&qPBI^f zN4Izr*{gcb7qcg%>vh3spHNiJpuMs2UC6#Rpp8*q!Jp2s@}}8x)0~4kF`L`4*W4}X zg?}o4Cs-u&q)??`E`gTiN)FC-dG%u5%;znu2yJsJWJWpW zw&%UWfUC1s3%ELiHNCWuizMYO#pk>xcaPWJd{9t!{b@dug+=LGUc?O&aEC~ksu;>|$S#KtTbf=dd;UI4K(J$5aD;iK@cM_g zp69cb{Zs1n`ok7EuE+fw7h-tGKjw|mm$o<;wk#GhS+Bp|FAE8zf-=X2^O$_D9s#%B zGuVdxi#AeI@mT!AF&eDQ^@y@8(43Nc3o~Ke<-tYFAAWq`3y@Y`RLPa)f~7DZb^O{q z^ifLG zk21SsQm8#HwSU5>xpF4)tV;-fyvZYIYDl;|~ zv>VrxYq=GD*RkS?$A$8kyu`4yf|}D`0uhU&~kM?PFR&*dmi`JhzkW2?PZGVL@m`nX;G8pkclos-;wO2NDV8=y@M#3c4CsZl zDm7}Tq`S2N{dsY{}If=nHM$FYd1_HvCHCgu3#i|NxhdVd|8L#Fp>l0Q0 zd8nmi39@_-zLLVE99wrS?hKTXX>lhn_4Iolr2D49&#y6#n{-o~Gb$QK$N>lKrY1ss zB=#GXj)EFG9iCF7!Pc79L7fZm0nk>3nXV9fZYNPUMW=g%gG!Ya6UCh*0zP6Tt~`v z2LLYyxkq-?TjKjMD%5KDP`D|*VSR0ZD{j}#0RMO$o@ufis2lU=_~t+~Y< zXGm>(JqcZ6>2e5jAh{?%pk7(wa>R#kvnI(isgo%20%NlUGb5U8#HO$94TSN0U(0`TDh^u`I-EGA$#A}~Wh;jzd zgr!E)Wu?3!@ZYk(cM%;}mqaV>-GFDt`;%XVRNkPYMBUx%}hq}nnF zS6EW;@vqx*#dXeV>0CXJxGvwODQ?|BgPY?y@vPNylDJftUpLRe z1q@XryN2AqIQ!xnz$!jNP{D-V&!R1e^`(v3L2tY?C2}_N=GU6*MLo9oUpMbxDf-_( zz=j}J zM~Z0#lYgv(zy^U2b5yvD^|PfijUykrem(M1w2jCfz+W&Jw_4g!h@NGbT4E`44($)I z*t2rG#gX@p`{f3-AWq{Qa1Tr&9T)#t?&BZD>dR^XQx_K!xA%yD6|!lBoSU2qs&wq` zTm4ZsVED_`>)%r@aJ}6!6P>a-3O+zDgdkFrTcNPZ<~nVPTryfMk|P7hRW;VptyqrrAA1?cv^}t!uR9W zS}a3%Fuin4s@ttv7Jb?xp@o>}AVw$ojuRzPpL1Rg%5 zQ;$s0^uvdcWPybXJx8S4h8i}3bH9pNguQ|qf2uQAp;{@X^fJSo2hxQSV?DDhSEIh{ zU3hR9B7p>vn^Qv$LvrbO>l<+_JrzCf7Bj{m8TNP^3+`YuNLk0I!_k?eXWC)fv^E)$ zYNlo57j%qTH-Bg->X5tjwe<*xhSfMFq=q^feIx~8G^(eo{E_}WidS%$e>X#<=R1(# ze^*-3BHtSgXT|ZtuFCSIuQ=+Dnd?Pa9LKc)jr}rG*ur1>`?@RZrr@Nazudjg${$y~ zu|czF_s-JO&it1T8b+&4QyQznzQlH3`*3}u^6(;gWZ3a+U`dxmZ&UE}j8gL5f=?Qd znhEa2f#eCyuC1fK*O@;atWtL!lU5Cc-$q%<=D;H66H`O&jIQ7vh83j(ck%eZJveNl zoMu%a#5#sR-Z=(ld$nk%_&A~3NN|jm_z6Bu zrS7b&&c;fdk392XCaPJL(M^7{oj%G;(1UPl>G0b6=U*;>QP;VQnhD!pbv)jhDchXk zCqXG34Rg$v*1)oceXH>U#=}B!?h%-I_vat_218VG#hIJC;lo#Ya8KLvp>oC2iK$6 z6-^5fr_<-bVy&WG;nTu)3fI0?@NxncnWyeieMdf z3>5k!AUI`&ASZ030W!k@2435eM-rm&j`44+g3dTUxC>DB;Y8mKBYwHo!yiAgss$M;KpB8 z%e_I6bbYLyLEuiFjM*?UU43jj4(SV%>Prqv>qs~LKfu)QP=>k_mYl%JnO-wtkYhsI znC}-L8Ookl6*s!Xn>gdv1o5A=N?3`c^HFjf5-9#KElrZMWO93#5jW%f)YR!~tJcaq zisPCtIe$fwW&HFpWF@)?0nZkjnGNXk<6MZW4Pcfs4ox1&)GgYVaWHF{AwE~TR~ecW zdKZ_0Ed)>TVQ8>^)YiDQGw71bi`6mjyG0+SX>txU{fs3|aqjMkXyNxhF_s4|La4}n z=k0UBARx6tk~@&xZK7-Lp+#`QPFbs8l{$7t zqK1z2KQYK(u(@d4mKgUkRF$#_T^;`NP8>ne}zy0;XO6i;T} z@@#zCE@yPKE?fi$)ceP~!C1-NTR$2=_cwIs{wLn@U|;7?i&@OFcu`cPWHqO>RJCqd zz`GMa#L$4nch4iN7Mz3g2qbP?JX_==-1pq`XNQnJ~2fGEqk>)>xRqQ6MlgP_vlTE1v%m&1XK6ChvPh5Y2?A-O6?HO%Id$9Xln$ z#Z~jxIw={v&%eNEhK}*Wf5K?MhX zS->d-ti_Ac>l&<^({K@S+Vyy&3MSlE!mWYq?FpGP`W^)k)@7E82X}Usc4nAYTq?*M z4TklI+8;ojZ|t&+ch#U)I|dk$VRJ73B%Jh+Wt)_gy*FiRuQEGBlv5l*`cU?cFT*zM zz9}dtIr@u;yLps<50U!=W@6`HVuuR;mTsJJaFeHp!l<&ELLS8GtH>o*kqh%0@~;<= z#sJu%Zn_7E>+J0-ok8jo-0kINo~6yMg&pj!xh#M$_61_>V%jQA=*bwaDn2rxp#mr( zp+g3(XcB9jAzR1`wIVb9k*ROrPrM(NA_L>K$1tsSR%#T6pH zPX+CcTmJshNc<_Sr(SEALfDCV-%aOY>`*q{4O=|mgZ?I~-Yb%tp~5D`aqqz{dJT4w zcO3Tl5R3D1V?}KM<%@sJ2I}@8l5$2T+q%8M*M6q+Wv}d`EMvB!QHpj*_X5S_54FuN z@x))jO-%F*l?W9cOZL>s6;-Z)+CdgFm}t_p0U1rB1PZQ z&&&`4X#(z)?Na}=Co?x_g2#3R_uS@LTV|T95nmxvF+MDFQHXftm~kVAAb_K={jD4` zUq7)=zcPVFSN(h)0o<5d%XU`Vquzr&GMl8kad#}6L>;v)RwtEt;^R~%H81x2-NEIh zL}!)(38|Y0Hx4PO^_lZRbwIgxEoD2mF>8B%#wzxE@RAX@A=*wB0be42t9!)9LjVOwYbzq(#px@vCC1)J}x8Ni|mAK1fG{ zCHT-&#)fKPAW?+$)|}Kf&4nU~bH3o5DxTq}ec-PiM19wz?GJPxH zvYg}ClHG)?$WZ3utTVDukeL4MXPZ-oW_=z3o8AC!9zU&1ELWh#$>*9)KlmLRzYEC< z_VF#~IN{@hobxb*SGP^ms{Nm*!VwuXREXgM#?VL+CTktP9=UeUr-&zj%c(cg2XJ#V ziAzKsyPc7Ib#ywEh13V5bal%the;`=4mJWqQsJ(NGmdY4NJw2B&|iX$z$e`PjZ*H>oCO0Ixi7 z)fo|gJ$S#P^q;5t9mB#7;#-CCeL9Bq_` vyCz%sesi5M1NjO){D{Dk<;zPk#(n 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 C+f)?* delta 11384 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 (H28K6KQD`p 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 From 6480d8cba751454b402923d06a99f6556141c14e Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 5 Oct 2021 14:52:11 +0200 Subject: [PATCH 855/980] fix(permissions): rename IsNotBilledAndVerified permissions rename permission to depict its functionality --- timed/permissions.py | 4 ++-- timed/tracking/views.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/timed/permissions.py b/timed/permissions.py index 8490a967f..2baac635a 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -161,8 +161,8 @@ def has_object_permission(self, request, view, obj): return not obj.transferred -class IsNotBilledAndVerfied(BasePermission): - """Allows access only to not billed and not verfied objects.""" +class IsNotBilledOrVerified(BasePermission): + """Allows access only to not billed or not verfied objects.""" def has_object_permission(self, request, view, obj): return not obj.billed or obj.verified_by_id is None diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 11c492262..f2a72f46e 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -18,7 +18,7 @@ IsAuthenticated, IsExternal, IsInternal, - IsNotBilledAndVerfied, + IsNotBilledOrVerified, IsNotDelete, IsNotTransferred, IsOwner, @@ -96,9 +96,9 @@ class ReportViewSet(ModelViewSet): permission_classes = [ # superuser and accountants may edit all reports but not delete (IsSuperUser | IsAccountant) & IsNotDelete - # reviewer and supervisor may change reports which are not verfied and billed + # reviewer and supervisor may change reports which aren't verfied or billed # but not delete them - | (IsReviewer | IsSupervisor) & IsNotBilledAndVerfied & IsNotDelete + | (IsReviewer | IsSupervisor) & IsNotBilledOrVerified & IsNotDelete # internal employees may only change its own unverified reports # only external employees with resource role may only change its own unverified reports | IsOwner & IsUnverified & (IsInternal | (IsExternal & IsResource)) From 20f94aef2f94022dc053cf45080d92bdfa8e5a83 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 13 Oct 2021 13:23:10 +0200 Subject: [PATCH 856/980] fix(tracking): set correct value for billed flag on reports use projects billed flag on new reports set billed flag on updated reports according to project update all corresponding reports after setting the billed flag remove billed permission update factory-boy dependancy update snapshot --- requirements-dev.txt | 2 +- timed/permissions.py | 7 --- timed/projects/models.py | 23 ++++++++++ timed/projects/tests/test_project.py | 18 ++++++++ timed/tracking/serializers.py | 7 +++ .../tests/snapshots/snap_test_report.py | 44 +++++++++---------- timed/tracking/views.py | 9 ++-- 7 files changed, 75 insertions(+), 35 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 58a363a4c..82cd197b5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ -r requirements.txt black==21.6b0 coverage==5.5 -factory-boy==3.2.0 +factory-boy==3.2.1 flake8==3.9.2 flake8-blind-except==0.2.0 flake8-debugger==4.0.0 diff --git a/timed/permissions.py b/timed/permissions.py index 2baac635a..b7edd18e8 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -161,13 +161,6 @@ def has_object_permission(self, request, view, obj): return not obj.transferred -class IsNotBilledOrVerified(BasePermission): - """Allows access only to not billed or not verfied objects.""" - - def has_object_permission(self, request, view, obj): - return not obj.billed or obj.verified_by_id is None - - class IsInternal(IsAuthenticated): """Allows access only to internal employees.""" diff --git a/timed/projects/models.py b/timed/projects/models.py index e3c13e2fb..d00fe5cd9 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -2,8 +2,13 @@ from django.conf import settings from django.db import models +from django.db.models import Q +from django.db.models.signals import pre_save +from django.dispatch import receiver from djmoney.models.fields import MoneyField +from timed.tracking.models import Report + class Customer(models.Model): """Customer model. @@ -242,3 +247,21 @@ class TaskAssignee(models.Model): is_resource = models.BooleanField(default=False) is_reviewer = models.BooleanField(default=False) is_manager = models.BooleanField(default=False) + + +@receiver(pre_save, sender=Project) +def update_billed_flag_on_reports(sender, instance, **kwargs): + """Update billed flag on all reports from the updated project. + + Only update reports if the billed flag on the project was changed. + Setting the billed flag to True on a project in Django Admin should set + all existing reports to billed=True. Same goes for setting the flag to billed=False. + The billed flag should primarily be set in frontend. + This is only a quicker way for the accountants to update all reports at once. + """ + # check whether the project was created or is being updated + if instance.pk: + if instance.billed != Project.objects.get(id=instance.id).billed: + Report.objects.filter(Q(task__project=instance)).update( + billed=instance.billed + ) diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index ec4a1c98a..3b6b02da6 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -146,3 +146,21 @@ def test_project_filter(internal_employee_client): json = response.json() assert len(json["data"]) == 1 + + +def test_project_update_billed_flag(internal_employee_client, report_factory): + report = report_factory.create() + project = report.task.project + assert not report.billed + + project.billed = True + project.save() + + report.refresh_from_db() + assert report.billed + + project.billed = False + project.save() + + report.refresh_from_db() + assert not report.billed diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index 42b1af6f6..e216e1527 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -169,9 +169,16 @@ def validate(self, data): if not user.is_accountant and billed: raise ValidationError(_("Only accountants may bill reports.")) + # update billed flag on created reports if not self.instance or billed is None: data["billed"] = task.project.billed + # update billed flag on reports that are being moved to a different project + # according to the billed flag of the project the report was moved to + if self.instance and data.get("task"): + if self.instance.task.id != data.get("task").id: + data["billed"] = data.get("task").project.billed + current_employment = Employment.objects.get_at(user=user, date=date.today()) if ( diff --git a/timed/tracking/tests/snapshots/snap_test_report.py b/timed/tracking/tests/snapshots/snap_test_report.py index 7ad0564ab..2ce98f5e7 100644 --- a/timed/tracking/tests/snapshots/snap_test_report.py +++ b/timed/tracking/tests/snapshots/snap_test_report.py @@ -15,20 +15,35 @@ Reviewer: Test User -Date: 06/27/1970 +Date: 10/03/1998 +Duration: 3:15 (h:mm) + + + +* Task + [old] Allen Inc > Cross-platform content-based synergy > and Sons + [new] Allen Inc > Cross-platform content-based synergy > LLC + +* Comment + [old] foo + [new] some other comment + +--- + +Date: 05/27/2000 Duration: 2:30 (h:mm) Comment: some other comment * Task - [old] Allen Inc > Cross-platform content-based synergy > and Sons - [new] Allen Inc > Cross-platform content-based synergy > Group + [old] Allen Inc > Cross-platform content-based synergy > Ltd + [new] Allen Inc > Cross-platform content-based synergy > LLC --- -Date: 11/02/1975 +Date: 04/20/2005 Duration: 0:15 (h:mm) -Task: Allen Inc > Cross-platform content-based synergy > Group +Task: Allen Inc > Cross-platform content-based synergy > LLC Comment: some other comment * Not_Billable @@ -37,24 +52,9 @@ --- -Date: 05/14/1976 -Duration: 3:15 (h:mm) - - - -* Task - [old] Allen Inc > Cross-platform content-based synergy > Ltd - [new] Allen Inc > Cross-platform content-based synergy > Group - -* Comment - [old] foo - [new] some other comment - ---- - -Date: 04/20/2005 +Date: 03/23/2016 Duration: 1:00 (h:mm) -Task: Allen Inc > Cross-platform content-based synergy > Group +Task: Allen Inc > Cross-platform content-based synergy > LLC * Comment diff --git a/timed/tracking/views.py b/timed/tracking/views.py index f2a72f46e..c8ac4d49b 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -18,7 +18,6 @@ IsAuthenticated, IsExternal, IsInternal, - IsNotBilledOrVerified, IsNotDelete, IsNotTransferred, IsOwner, @@ -96,9 +95,8 @@ class ReportViewSet(ModelViewSet): permission_classes = [ # superuser and accountants may edit all reports but not delete (IsSuperUser | IsAccountant) & IsNotDelete - # reviewer and supervisor may change reports which aren't verfied or billed - # but not delete them - | (IsReviewer | IsSupervisor) & IsNotBilledOrVerified & IsNotDelete + # reviewer and supervisor may change reports which aren't verfied but not delete them + | (IsReviewer | IsSupervisor) & IsUnverified & IsNotDelete # internal employees may only change its own unverified reports # only external employees with resource role may only change its own unverified reports | IsOwner & IsUnverified & (IsInternal | (IsExternal & IsResource)) @@ -250,7 +248,8 @@ def bulk(self, request): ) if "task" in fields: - fields["billed"] = fields["task"].project.billed + if fields["task"].project.billed: + fields["billed"] = fields["task"].project.billed if fields: tasks.notify_user_changed_reports(queryset, fields, user) From 8e09aac3668d5be98bb5592c45a7ec9c2ee291e5 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 2 Nov 2021 12:40:45 +0100 Subject: [PATCH 857/980] feat(reports): change column for total hours for tasks --- timed/reports/templates/workreport.ots | Bin 159977 -> 159792 bytes timed/reports/views.py | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index 5e8f22caba03645c7a08aac40178decfbf30ac4c..7c4a9e68e8adb4ddb2c31cec3ab37254a9c41f3b 100644 GIT binary patch delta 11655 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 S;Ab0fmotzCM#>KO2lO9XP~hJH delta 11938 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 Date: Wed, 10 Nov 2021 12:29:07 +0100 Subject: [PATCH 858/980] fix(fixtures): add user with accountant role This will also add a command in Makefile which allows to start a dbshell. --- Makefile | 3 +++ timed/fixtures/test_data.json | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Makefile b/Makefile index 39d675322..139abb94d 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ test: ## Test the project shell: ## Shell into the backend @docker-compose exec backend bash +dbshell: ## Start a psql shell + @docker-compose exec db psql -Utimed timed + flush: ## Flush database contents @docker-compose exec backend ./manage.py flush --no-input diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json index 334b4f040..c9d7f2249 100644 --- a/timed/fixtures/test_data.json +++ b/timed/fixtures/test_data.json @@ -173,6 +173,27 @@ "supervisors": [] } }, + { + "model": "employment.user", + "pk": 4, + "fields": { + "password": "", + "last_login": null, + "is_superuser": false, + "username": "jasminem", + "first_name": "Jasmine", + "email": "jasmine@example.com", + "is_accountant": true, + "is_staff": false, + "is_active": true, + "date_joined": "2020-03-12T09:28:55Z", + "tour_done": true, + "last_name": "Meier", + "groups": [], + "user_permissions": [], + "supervisors": [] + } + }, { "model": "projects.project", "pk": 1, @@ -260,6 +281,20 @@ "updated": "2020-03-19T09:27:21.761Z" } }, + { + "model": "employment.employment", + "pk": 4, + "fields": { + "user": 4, + "location": 1, + "percentage": 100, + "worktime_per_day": "08:00:00", + "start_date": "2020-01-01", + "end_date": null, + "added": "2020-03-19T09:27:21.761Z", + "updated": "2020-03-19T09:27:21.761Z" + } + }, { "model": "employment.overtimecredit", "pk": 1, From e9b9df21b388558c2c476f45f0dea14839aed122 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Wed, 10 Nov 2021 15:52:17 +0100 Subject: [PATCH 859/980] feat: include user in report-intersection --- timed/tracking/serializers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index e216e1527..baa94ff59 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -14,7 +14,7 @@ ValidationError, ) -from timed.employment.models import AbsenceType, Employment, PublicHoliday +from timed.employment.models import AbsenceType, Employment, PublicHoliday, User from timed.employment.relations import CurrentUserResourceRelatedField from timed.projects.models import Customer, Project, Task from timed.serializers import TotalTimeRootMetaMixin @@ -268,6 +268,9 @@ class ReportIntersectionSerializer(Serializer): task = relations.SerializerMethodResourceRelatedField( source="get_task", model=Task, read_only=True ) + user = relations.SerializerMethodResourceRelatedField( + source="get_user", model=User, read_only=True + ) comment = SerializerMethodField() review = SerializerMethodField() not_billable = SerializerMethodField() @@ -299,6 +302,9 @@ def get_project(self, instance): def get_task(self, instance): return self._intersection(instance, "task", Task) + def get_user(self, instance): + return self._intersection(instance, "user", User) + def get_comment(self, instance): return self._intersection(instance, "comment") @@ -332,6 +338,7 @@ def get_root_meta(self, resource, many): "customer": "timed.projects.serializers.CustomerSerializer", "project": "timed.projects.serializers.ProjectSerializer", "task": "timed.projects.serializers.TaskSerializer", + "user": "timed.employment.serializers.UserSerializer", } class Meta: From a9bc8ab9a92ecf21fe7c5010875bda68226649e2 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Thu, 11 Nov 2021 11:18:13 +0100 Subject: [PATCH 860/980] fix(filters): correct filtering for accountants This fixes the filtering of editable and non-editable reports for accountants. --- timed/tracking/filters.py | 12 ++-- timed/tracking/tests/test_report.py | 90 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 745413707..c9597c02a 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -127,8 +127,7 @@ def filter_editable(self, queryset, name, value): user. If set to `0` to not editable. """ user = self.request.user - - editable_filter = ( + assignee_filter = ( # avoid duplicates by using subqueries instead of joins Q(user__in=user.supervisees.values("id")) | Q( @@ -144,13 +143,16 @@ def filter_editable(self, queryset, name, value): task__project__customer__customer_assignees__is_reviewer=True, ) | Q(user=user) - ) & ~(Q(verified_by__isnull=False) & Q(billed=True)) + ) + unfinished_filter = Q(verified_by__isnull=True) | Q(billed=False) + editable_filter = assignee_filter & unfinished_filter if value: # editable if user.is_superuser: # superuser may edit all reports return queryset - + elif user.is_accountant: + return queryset.filter(unfinished_filter) # only owner, reviewer or supervisor may change unverified reports queryset = queryset.filter(editable_filter).distinct() @@ -159,6 +161,8 @@ def filter_editable(self, queryset, name, value): if user.is_superuser: # no reports which are not editable return queryset.none() + elif user.is_accountant: + return queryset.exclude(unfinished_filter) queryset = queryset.exclude(editable_filter) return queryset diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 91d59a61a..52dd92f45 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -90,6 +90,7 @@ def test_report_intersection_full( "data": {"id": str(report.task.project.id), "type": "projects"} }, "task": {"data": {"id": str(report.task.id), "type": "tasks"}}, + "user": {"data": {"id": str(report.user.id), "type": "users"}}, }, }, "meta": {"count": 1}, @@ -128,6 +129,7 @@ def test_report_intersection_partial( "customer": {"data": None}, "project": {"data": None}, "task": {"data": None}, + "user": {"data": None}, }, }, "meta": {"count": 2}, @@ -135,6 +137,94 @@ def test_report_intersection_partial( assert json == expected +def test_report_intersection_accountant_editable( + internal_employee_client, + report_factory, + user_factory, +): + user = internal_employee_client.user + user.is_accountant = True + user.save() + + other_user = user_factory() + report_factory.create(review=True, not_billable=True, user=other_user) + + report1 = report_factory.create(review=True, not_billable=True, user=other_user) + report1.billed = True + report1.save() + + url = reverse("report-intersection") + response = internal_employee_client.get(url, {"editable": 1}) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + expected = { + "data": { + "id": "editable=1", + "type": "report-intersections", + "attributes": { + "comment": None, + "not-billable": True, + "verified": False, + "review": True, + "billed": None, + }, + "relationships": { + "customer": {"data": None}, + "project": {"data": None}, + "task": {"data": None}, + "user": {"data": {"id": str(other_user.id), "type": "users"}}, + }, + }, + "meta": {"count": 2}, + } + assert json == expected + + +def test_report_intersection_accountant_not_editable( + internal_employee_client, + report_factory, + user_factory, +): + user = internal_employee_client.user + user.is_accountant = True + user.save() + + other_user = user_factory() + report_factory.create(review=True, not_billable=True, user=other_user) + + report = report_factory.create(review=True, not_billable=True, user=other_user) + report.billed = True + report.save() + + url = reverse("report-intersection") + response = internal_employee_client.get(url, {"editable": 0}) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + expected = { + "data": { + "id": "editable=0", + "type": "report-intersections", + "attributes": { + "comment": None, + "not-billable": None, + "verified": None, + "review": None, + "billed": None, + }, + "relationships": { + "customer": {"data": None}, + "project": {"data": None}, + "task": {"data": None}, + "user": {"data": None}, + }, + }, + "meta": {"count": 0}, + } + assert json == expected + + def test_report_list_filter_id( internal_employee_client, report_factory, From 846ac89b8bff91fab0e86109faeaf56531f81db3 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Thu, 11 Nov 2021 14:13:19 +0100 Subject: [PATCH 861/980] fix(filters): billing should not affect finish state of a report This is especially the case with projects that are billed in advance. --- timed/tracking/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index c9597c02a..8bc2232eb 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -144,7 +144,7 @@ def filter_editable(self, queryset, name, value): ) | Q(user=user) ) - unfinished_filter = Q(verified_by__isnull=True) | Q(billed=False) + unfinished_filter = Q(verified_by__isnull=True) editable_filter = assignee_filter & unfinished_filter if value: # editable From 484b768645d265668e201667df41497153e0831b Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 9 Nov 2021 10:13:14 +0100 Subject: [PATCH 862/980] feat(settings): add DATA_UPLOAD_MAX_NUMBER_FIELDS to env --- timed/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/timed/settings.py b/timed/settings.py index 44cf236d2..4e0312a42 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -357,3 +357,6 @@ def parse_admins(admins): ) SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +DATA_UPLOAD_MAX_NUMBER_FIELDS = env.int( + "DJANGO_DATA_UPLOAD_MAX_NUMBER_FIELDS", default=1000 +) From 7f907d8671ab43b7cbe4dc40dffa4f420f66dd6b Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 18 Nov 2021 13:39:03 +0100 Subject: [PATCH 863/980] fix(tracking): update reviewer filter only show reports in which user is responsible for reviewing e.g. if user is project assignee with reviewer role, don't show reports in which according task assignee with reviewer role is set --- timed/tracking/filters.py | 47 ++++++++++++++++++++++++----- timed/tracking/tests/test_report.py | 14 ++++++++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index 8bc2232eb..ebce4a127 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -102,24 +102,57 @@ class ReportFilterSet(FilterSet): def filter_has_reviewer(self, queryset, name, value): if not value: # pragma: no cover return queryset - return queryset.filter( + + # reports in which user is customer assignee and responsible reviewer + reports_customer_assignee_is_reviewer = queryset.filter( Q( - task_id__in=TaskAssignee.objects.filter( + task__project__customer_id__in=CustomerAssignee.objects.filter( is_reviewer=True, user_id=value - ).values("task_id"), + ).values("customer_id") + ) + ).exclude( + Q( + task__project_id__in=ProjectAssignee.objects.filter( + is_reviewer=True + ).values("project_id") ) | Q( + task_id__in=TaskAssignee.objects.filter(is_reviewer=True).values( + "task_id" + ) + ) + ) + + # reports in which user is project assignee and responsible reviewer + reports_project_assignee_is_reviewer = queryset.filter( + Q( task__project_id__in=ProjectAssignee.objects.filter( is_reviewer=True, user_id=value - ).values("project_id"), + ).values("project_id") ) - | Q( - task__project__customer_id__in=CustomerAssignee.objects.filter( + ).exclude( + Q( + task_id__in=TaskAssignee.objects.filter(is_reviewer=True).values( + "task_id" + ) + ) + ) + + # reports in which user task assignee and responsible reviewer + reports_task_assignee_is_reviewer = queryset.filter( + Q( + task_id__in=TaskAssignee.objects.filter( is_reviewer=True, user_id=value - ).values("customer_id"), + ).values("task_id") ) ) + return ( + reports_customer_assignee_is_reviewer + | reports_project_assignee_is_reviewer + | reports_task_assignee_is_reviewer + ) + def filter_editable(self, queryset, name, value): """Filter reports whether they are editable by current user. diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 52dd92f45..0d41c5880 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -9,7 +9,11 @@ from rest_framework import status from timed.employment.factories import EmploymentFactory, UserFactory -from timed.projects.factories import ProjectAssigneeFactory, TaskAssigneeFactory +from timed.projects.factories import ( + ProjectAssigneeFactory, + TaskAssigneeFactory, + TaskFactory, +) def test_report_list( @@ -270,6 +274,14 @@ def test_report_list_filter_reviewer( user=user, project=report.task.project, is_reviewer=True ) + # add new task to the project + task2 = TaskFactory.create(project=report.task.project) + report_factory.create(user=user, task=task2) + + # add task assignee with reviewer role to the new task + user2 = UserFactory.create() + TaskAssigneeFactory.create(user=user2, task=task2, is_reviewer=True) + url = reverse("report-list") response = internal_employee_client.get(url, data={"reviewer": user.id}) From 18a4394eaa46cc404df4a259a77e16f4ba2af827 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 24 Nov 2021 13:58:22 +0100 Subject: [PATCH 864/980] feat: update visibility to not depend on employment update visibility in Report-, User-, Customer-, Project- and TaskViewSet --- timed/employment/tests/test_user.py | 30 +++++++++++- timed/employment/views.py | 44 +++++++++-------- timed/projects/tests/test_customer.py | 22 +++++++++ timed/projects/tests/test_project.py | 22 +++++++++ timed/projects/tests/test_task.py | 22 +++++++++ timed/projects/views.py | 70 ++++++++++++++++----------- timed/tracking/tests/test_report.py | 37 ++++++++++++++ timed/tracking/views.py | 39 +++++++++------ 8 files changed, 223 insertions(+), 63 deletions(-) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 3e67d6ccd..f6f593c6e 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -9,7 +9,11 @@ EmploymentFactory, UserFactory, ) -from timed.projects.factories import ProjectAssigneeFactory, ProjectFactory +from timed.projects.factories import ( + CustomerAssigneeFactory, + ProjectAssigneeFactory, + ProjectFactory, +) from timed.tracking.factories import AbsenceFactory, ReportFactory @@ -264,3 +268,27 @@ def test_user_me_anonymous(client): response = client.get(url) assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.parametrize( + "is_assigned, expected, status_code", + [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], +) +def test_user_list_assignee_no_employment( + auth_client, is_assigned, expected, status_code +): + user = auth_client.user + UserFactory.create_batch(2) + if is_assigned: + CustomerAssigneeFactory.create(user=user) + + url = reverse("user-list") + + response = auth_client.get(url) + + assert response.status_code == status_code + + if expected: + json = response.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(user.id) diff --git a/timed/employment/views.py b/timed/employment/views.py index 252d5e4de..41f47f604 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -24,7 +24,7 @@ IsSupervisor, IsUpdateOnly, ) -from timed.projects.models import Task +from timed.projects.models import CustomerAssignee, Task from timed.tracking.models import Absence, Report @@ -53,32 +53,36 @@ class UserViewSet(ModelViewSet): def get_queryset(self): user = self.request.user - current_employment = models.Employment.objects.get_at( - user=user, date=datetime.date.today() - ) queryset = get_user_model().objects.prefetch_related( "employments", "supervisees", "supervisors" ) - if current_employment.is_external: - assigned_tasks = Task.objects.filter( - Q(task_assignees__user=user, task_assignees__is_reviewer=True) - | Q( - project__project_assignees__user=user, - project__project_assignees__is_reviewer=True, + try: + current_employment = models.Employment.objects.get_at( + user=user, date=datetime.date.today() + ) + if current_employment.is_external: + assigned_tasks = Task.objects.filter( + Q(task_assignees__user=user, task_assignees__is_reviewer=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + ) ) - | Q( - project__customer__customer_assignees__user=user, - project__customer__customer_assignees__is_reviewer=True, + visible_reports = Report.objects.all().filter( + Q(task__in=assigned_tasks) | Q(user=user) ) - ) - visible_reports = Report.objects.all().filter( - Q(task__in=assigned_tasks) | Q(user=user) - ) - - return queryset.filter(Q(reports__in=visible_reports) | Q(id=user.id)) - return queryset + return queryset.filter(Q(reports__in=visible_reports) | Q(id=user.id)) + return queryset + except models.Employment.DoesNotExist: + if CustomerAssignee.objects.filter(user=user).exists(): + return queryset.filter(Q(id=user.id)) + raise exceptions.PermissionDenied("User has no employment") @action(methods=["get"], detail=False) def me(self, request, pk=None): diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 9af9d6ed9..2a4fb5b94 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -71,3 +71,25 @@ def test_customer_list_external_employee( json = response.json() assert len(json["data"]) == expected + + +@pytest.mark.parametrize( + "is_assigned, expected, status_code", + [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], +) +def test_customer_list_user_assignee_no_employment( + auth_client, is_assigned, expected, status_code +): + CustomerFactory.create_batch(4) + customer = CustomerFactory.create() + if is_assigned: + customer.assignees.add(auth_client.user) + + url = reverse("customer-list") + + response = auth_client.get(url) + assert response.status_code == status_code + + if expected: + json = response.json() + assert len(json["data"]) == expected diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 3b6b02da6..57c970d6f 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -164,3 +164,25 @@ def test_project_update_billed_flag(internal_employee_client, report_factory): report.refresh_from_db() assert not report.billed + + +@pytest.mark.parametrize( + "is_assigned, expected, status_code", + [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], +) +def test_project_list_user_assignee_no_employment( + auth_client, is_assigned, expected, status_code +): + ProjectFactory.create_batch(4) + project = ProjectFactory.create() + if is_assigned: + project.customer.assignees.add(auth_client.user) + + url = reverse("project-list") + + response = auth_client.get(url) + assert response.status_code == status_code + + if expected: + json = response.json() + assert len(json["data"]) == expected diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 32b2005f9..8e8281c28 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -195,3 +195,25 @@ def test_task_list_external_employee(external_employee_client, is_assigned, expe json = response.json() assert len(json["data"]) == expected + + +@pytest.mark.parametrize( + "is_assigned, expected, status_code", + [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], +) +def test_task_list_user_assignee_no_employment( + auth_client, is_assigned, expected, status_code +): + TaskFactory.create_batch(4) + task = TaskFactory.create() + if is_assigned: + task.project.customer.assignees.add(auth_client.user) + + url = reverse("task-list") + + response = auth_client.get(url) + assert response.status_code == status_code + + if expected: + json = response.json() + assert len(json["data"]) == expected diff --git a/timed/projects/views.py b/timed/projects/views.py index 7034b8624..3e98c2187 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -3,6 +3,7 @@ from datetime import date from django.db.models import Q +from rest_framework.exceptions import PermissionDenied from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from timed.employment.models import Employment @@ -32,17 +33,22 @@ def get_queryset(self): :rtype: QuerySet """ user = self.request.user - current_employment = Employment.objects.get_at(user=user, date=date.today()) queryset = models.Customer.objects.prefetch_related("projects") - if not current_employment.is_external: # pragma: no cover - return queryset - else: - return queryset.filter( - Q(assignees=user) - | Q(projects__assignees=user) - | Q(projects__tasks__assignees=user) - ) + try: + current_employment = Employment.objects.get_at(user=user, date=date.today()) + if not current_employment.is_external: # pragma: no cover + return queryset + else: + return queryset.filter( + Q(assignees=user) + | Q(projects__assignees=user) + | Q(projects__tasks__assignees=user) + ) + except Employment.DoesNotExist: + if models.CustomerAssignee.objects.filter(user=user).exists(): + return queryset.filter(Q(assignees=user)) + raise PermissionDenied("User has no employment and isn't a customer!") class BillingTypeViewSet(ReadOnlyModelViewSet): @@ -85,21 +91,26 @@ class ProjectViewSet(ReadOnlyModelViewSet): def get_queryset(self): """Get only assigned projects, if an employee is external.""" user = self.request.user - current_employment = Employment.objects.get_at(user=user, date=date.today()) queryset = ( super() .get_queryset() .select_related("customer", "billing_type", "cost_center") ) - if not current_employment.is_external: # pragma: no cover - return queryset - else: - return queryset.filter( - Q(assignees=user) - | Q(tasks__assignees=user) - | Q(customer__assignees=user) - ) + try: + current_employment = Employment.objects.get_at(user=user, date=date.today()) + if not current_employment.is_external: # pragma: no cover + return queryset + else: + return queryset.filter( + Q(assignees=user) + | Q(tasks__assignees=user) + | Q(customer__assignees=user) + ) + except Employment.DoesNotExist: + if models.CustomerAssignee.objects.filter(user=user).exists(): + return queryset.filter(Q(customer__assignees=user)) + raise PermissionDenied("User has no employment and isn't a customer!") class TaskViewSet(ModelViewSet): @@ -131,17 +142,22 @@ def filter_queryset(self, queryset): def get_queryset(self): """Get only assigned tasks, if an employee is external.""" user = self.request.user - current_employment = Employment.objects.get_at(user=user, date=date.today()) queryset = super().get_queryset().select_related("project", "cost_center") - if not current_employment.is_external: # pragma: no cover - return queryset - else: - return queryset.filter( - Q(assignees=user) - | Q(project__assignees=user) - | Q(project__customer__assignees=user) - ) + try: + current_employment = Employment.objects.get_at(user=user, date=date.today()) + if not current_employment.is_external: # pragma: no cover + return queryset + else: + return queryset.filter( + Q(assignees=user) + | Q(project__assignees=user) + | Q(project__customer__assignees=user) + ) + except Employment.DoesNotExist: + if models.CustomerAssignee.objects.filter(user=user).exists(): + return queryset.filter(Q(project__customer__assignees=user)) + raise PermissionDenied("User has no employment and isn't a customer!") class TaskAsssigneeViewSet(ReadOnlyModelViewSet): diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 0d41c5880..9bcfda673 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -10,6 +10,7 @@ from timed.employment.factories import EmploymentFactory, UserFactory from timed.projects.factories import ( + CustomerAssigneeFactory, ProjectAssigneeFactory, TaskAssigneeFactory, TaskFactory, @@ -1643,3 +1644,39 @@ def test_report_list_external_employee(external_employee_client, report_factory) assert len(json["data"]) == 1 assert json["data"][0]["id"] == str(report.id) assert json["meta"]["total-time"] == "01:00:00" + + +@pytest.mark.parametrize( + "is_assigned, expected, status_code", + [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], +) +def test_report_list_user_assignee_no_employment( + auth_client, report_factory, is_assigned, expected, status_code +): + user = auth_client.user + report = report_factory.create(user=user, duration=timedelta(hours=1)) + if is_assigned: + CustomerAssigneeFactory.create(user=user, customer=report.task.project.customer) + report_factory.create_batch(4) + + url = reverse("report-list") + + response = auth_client.get( + url, + data={ + "date": report.date, + "user": user.id, + "task": report.task_id, + "project": report.task.project_id, + "customer": report.task.project.customer_id, + "include": ("user,task,task.project,task.project.customer,verified_by"), + }, + ) + + assert response.status_code == status_code + + json = response.json() + if expected: + assert len(json["data"]) == expected + assert json["data"][0]["id"] == str(report.id) + assert json["meta"]["total-time"] == "01:00:00" diff --git a/timed/tracking/views.py b/timed/tracking/views.py index c8ac4d49b..d15a55b25 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -28,7 +28,7 @@ IsSupervisor, IsUnverified, ) -from timed.projects.models import Task +from timed.projects.models import CustomerAssignee, Task from timed.serializers import AggregateObject from timed.tracking import filters, models, serializers @@ -121,28 +121,37 @@ class ReportViewSet(ModelViewSet): def get_queryset(self): """Get filtered reports for external employees.""" user = self.request.user - current_employment = Employment.objects.get_at(user=user, date=date.today()) queryset = super().get_queryset() queryset.select_related( "task", "user", "task__project", "task__project__customer" ) - if not current_employment.is_external: - return queryset + try: + current_employment = Employment.objects.get_at(user=user, date=date.today()) + if not current_employment.is_external: + return queryset - assigned_tasks = Task.objects.filter( - Q(task_assignees__user=user, task_assignees__is_reviewer=True) - | Q( - project__project_assignees__user=user, - project__project_assignees__is_reviewer=True, + assigned_tasks = Task.objects.filter( + Q(task_assignees__user=user, task_assignees__is_reviewer=True) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + ) ) - | Q( - project__customer__customer_assignees__user=user, - project__customer__customer_assignees__is_reviewer=True, + queryset = queryset.filter(Q(task__in=assigned_tasks) | Q(user=user)) + return queryset + except Employment.DoesNotExist: + if CustomerAssignee.objects.filter(user=user).exists(): + return queryset.filter( + Q(task__project__customer__customer_assignees__user=user) + ) + raise exceptions.PermissionDenied( + "User has no employment and isn't a customer!" ) - ) - queryset = queryset.filter(Q(task__in=assigned_tasks) | Q(user=user)) - return queryset def update(self, request, *args, **kwargs): """Override so we can issue emails on update.""" From 6ad9b02a6f8778e199e20f4dccf26caa4a1641e3 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 24 Nov 2021 13:58:22 +0100 Subject: [PATCH 865/980] feat: update visibility to not depend on employment update visibility in Report-, User-, Customer-, Project- and TaskViewSet update employment check in ReportSerializer --- timed/employment/tests/test_user.py | 2 +- timed/projects/tests/test_project.py | 2 +- timed/tracking/tests/test_report.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index f6f593c6e..eb5ac3c4a 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -35,7 +35,7 @@ def test_user_list(db, internal_employee_client, django_assert_num_queries): url = reverse("user-list") - with django_assert_num_queries(14): + with django_assert_num_queries(15): response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 57c970d6f..40aee1fb4 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -32,7 +32,7 @@ def test_project_list_include( url = reverse("project-list") - with django_assert_num_queries(2): + with django_assert_num_queries(3): response = internal_employee_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 9bcfda673..7aeddb252 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1200,7 +1200,7 @@ def test_report_export( url = reverse("report-export") - with django_assert_num_queries(2): + with django_assert_num_queries(3): response = internal_employee_client.get(url, data={"file_type": file_type}) assert response.status_code == status.HTTP_200_OK From 70818bb23efbe6377ebfff05ea54156b2f877ef4 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 25 Nov 2021 16:13:02 +0100 Subject: [PATCH 866/980] feat: create new assignee role create permissions for the new role update affected views fix IsReviewer, IsInternal, IsExternal and IsNotTransferred permission fix tests where needed add and update tests for Orders --- timed/employment/tests/test_user.py | 13 +- timed/employment/views.py | 13 +- timed/permissions.py | 43 +++- .../0014_add_is_customer_role_to_assignees.py | 28 +++ timed/projects/models.py | 3 + timed/projects/serializers.py | 27 ++- timed/projects/tests/test_customer.py | 14 +- timed/projects/tests/test_project.py | 20 +- timed/projects/tests/test_task.py | 18 +- timed/projects/views.py | 12 +- timed/subscription/serializers.py | 21 ++ timed/subscription/tests/test_order.py | 227 +++++++++++++++--- timed/subscription/views.py | 40 +-- timed/tracking/tests/test_report.py | 23 +- timed/tracking/views.py | 7 +- 15 files changed, 400 insertions(+), 109 deletions(-) create mode 100644 timed/projects/migrations/0014_add_is_customer_role_to_assignees.py diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index eb5ac3c4a..8fa1a37a4 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -35,7 +35,7 @@ def test_user_list(db, internal_employee_client, django_assert_num_queries): url = reverse("user-list") - with django_assert_num_queries(15): + with django_assert_num_queries(14): response = internal_employee_client.get(url) assert response.status_code == status.HTTP_200_OK @@ -271,21 +271,18 @@ def test_user_me_anonymous(client): @pytest.mark.parametrize( - "is_assigned, expected, status_code", + "is_customer, expected, status_code", [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], ) -def test_user_list_assignee_no_employment( - auth_client, is_assigned, expected, status_code -): +def test_user_list_no_employment(auth_client, is_customer, expected, status_code): user = auth_client.user UserFactory.create_batch(2) - if is_assigned: - CustomerAssigneeFactory.create(user=user) + if is_customer: + CustomerAssigneeFactory.create(user=user, is_customer=True) url = reverse("user-list") response = auth_client.get(url) - assert response.status_code == status_code if expected: diff --git a/timed/employment/views.py b/timed/employment/views.py index 41f47f604..01d786e14 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -80,8 +80,17 @@ def get_queryset(self): return queryset.filter(Q(reports__in=visible_reports) | Q(id=user.id)) return queryset except models.Employment.DoesNotExist: - if CustomerAssignee.objects.filter(user=user).exists(): - return queryset.filter(Q(id=user.id)) + if CustomerAssignee.objects.filter(user=user, is_customer=True).exists(): + assigned_tasks = Task.objects.filter( + Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_customer=True, + ) + ) + visible_reports = Report.objects.all().filter( + Q(task__in=assigned_tasks) | Q(user=user) + ) + return queryset.filter(Q(reports__in=visible_reports) | Q(id=user.id)) raise exceptions.PermissionDenied("User has no employment") @action(methods=["get"], detail=False) diff --git a/timed/permissions.py b/timed/permissions.py index b7edd18e8..1688ef25d 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -2,7 +2,6 @@ from datetime import date from django.db.models import Q -from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated from timed.employment import models as employment_models @@ -112,7 +111,13 @@ def has_permission(self, request, view): if not super().has_permission(request, view): # pragma: no cover return False - return True + if ( + request.user.customer_assignees.filter(is_reviewer=True).exists() + or request.user.project_assignees.filter(is_reviewer=True).exists() + or request.user.task_assignees.filter(is_reviewer=True).exists() + ): + return True + return False def has_object_permission(self, request, view, obj): if not super().has_object_permission(request, view, obj): # pragma: no cover @@ -154,7 +159,7 @@ def has_object_permission(self, request, view, obj): return self.has_permission(request, view) -class IsNotTransferred(BasePermission): +class IsNotTransferred(IsAuthenticated): """Allows access only to not transferred objects.""" def has_object_permission(self, request, view, obj): @@ -174,7 +179,12 @@ def has_permission(self, request, view): ) return not employment.is_external except employment_models.Employment.DoesNotExist: - raise PermissionDenied("User has no employment") + # if the user has no employment, check if he's a customer + if projects_models.CustomerAssignee.objects.filter( + user=request.user, is_customer=True + ).exists(): + return True + return False def has_object_permission(self, request, view, obj): if not super().has_object_permission(request, view, obj): # pragma: no cover @@ -201,7 +211,12 @@ def has_permission(self, request, view): ) return employment.is_external except employment_models.Employment.DoesNotExist: # pragma: no cover - raise PermissionDenied("User has no employment") + # if the user has no employment, check if he's a customer + if projects_models.CustomerAssignee.objects.filter( + user=request.user, is_customer=True + ).exists(): + return True + return False def has_object_permission(self, request, view, obj): if not super().has_object_permission(request, view, obj): # pragma: no cover @@ -305,8 +320,26 @@ def has_object_permission(self, request, view, obj): class IsAccountant(IsAuthenticated): """Allows access only to accountants.""" + def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + + return request.user.is_accountant + def has_object_permission(self, request, view, obj): if not super().has_object_permission(request, view, obj): # pragma: no cover return False return request.user.is_accountant + + +class IsCustomer(IsAuthenticated): + """Allows access only to assignees with customer role.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): # pragma: no cover + return False + + if request.user.customer_assignees.filter(is_customer=True).exists(): + return True + return False diff --git a/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py b/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py new file mode 100644 index 000000000..9984de6cf --- /dev/null +++ b/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.7 on 2021-11-25 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0013_remove_project_reviewers"), + ] + + operations = [ + migrations.AddField( + model_name="customerassignee", + name="is_customer", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="projectassignee", + name="is_customer", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="taskassignee", + name="is_customer", + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index d00fe5cd9..a1b5b2d3b 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -209,6 +209,7 @@ class CustomerAssignee(models.Model): is_resource = models.BooleanField(default=False) is_reviewer = models.BooleanField(default=False) is_manager = models.BooleanField(default=False) + is_customer = models.BooleanField(default=False) class ProjectAssignee(models.Model): @@ -228,6 +229,7 @@ class ProjectAssignee(models.Model): is_resource = models.BooleanField(default=False) is_reviewer = models.BooleanField(default=False) is_manager = models.BooleanField(default=False) + is_customer = models.BooleanField(default=False) class TaskAssignee(models.Model): @@ -247,6 +249,7 @@ class TaskAssignee(models.Model): is_resource = models.BooleanField(default=False) is_reviewer = models.BooleanField(default=False) is_manager = models.BooleanField(default=False) + is_customer = models.BooleanField(default=False) @receiver(pre_save, sender=Project) diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index a5e543933..9d7ff52b5 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -178,7 +178,14 @@ class Meta: """Meta information for the customer assignee serializer.""" model = models.CustomerAssignee - fields = ["user", "customer", "is_reviewer", "is_manager", "is_resource"] + fields = [ + "user", + "customer", + "is_reviewer", + "is_manager", + "is_resource", + "is_customer", + ] class ProjectAssigneeSerializer(ModelSerializer): @@ -193,7 +200,14 @@ class Meta: """Meta information for the project assignee serializer.""" model = models.ProjectAssignee - fields = ["user", "project", "is_reviewer", "is_manager", "is_resource"] + fields = [ + "user", + "project", + "is_reviewer", + "is_manager", + "is_resource", + "is_customer", + ] class TaskAssigneeSerializer(ModelSerializer): @@ -208,4 +222,11 @@ class Meta: """Meta information for the task assignee serializer.""" model = models.TaskAssignee - fields = ["user", "task", "is_reviewer", "is_manager", "is_resource"] + fields = [ + "user", + "task", + "is_reviewer", + "is_manager", + "is_resource", + "is_customer", + ] diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index 2a4fb5b94..b71a2ffbd 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -4,7 +4,7 @@ from django.urls import reverse from rest_framework import status -from timed.projects.factories import CustomerFactory +from timed.projects.factories import CustomerAssigneeFactory, CustomerFactory def test_customer_list_not_archived(internal_employee_client): @@ -74,16 +74,16 @@ def test_customer_list_external_employee( @pytest.mark.parametrize( - "is_assigned, expected, status_code", + "is_customer, expected, status_code", [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], ) -def test_customer_list_user_assignee_no_employment( - auth_client, is_assigned, expected, status_code -): +def test_customer_list_no_employment(auth_client, is_customer, expected, status_code): CustomerFactory.create_batch(4) customer = CustomerFactory.create() - if is_assigned: - customer.assignees.add(auth_client.user) + if is_customer: + CustomerAssigneeFactory.create( + user=auth_client.user, is_customer=True, customer=customer + ) url = reverse("customer-list") diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 40aee1fb4..640f364b2 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -6,7 +6,11 @@ from rest_framework import status from timed.employment.factories import UserFactory -from timed.projects.factories import ProjectAssigneeFactory, ProjectFactory +from timed.projects.factories import ( + CustomerAssigneeFactory, + ProjectAssigneeFactory, + ProjectFactory, +) from timed.projects.serializers import ProjectSerializer @@ -32,7 +36,7 @@ def test_project_list_include( url = reverse("project-list") - with django_assert_num_queries(3): + with django_assert_num_queries(2): response = internal_employee_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, @@ -167,16 +171,16 @@ def test_project_update_billed_flag(internal_employee_client, report_factory): @pytest.mark.parametrize( - "is_assigned, expected, status_code", + "is_customer, expected, status_code", [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], ) -def test_project_list_user_assignee_no_employment( - auth_client, is_assigned, expected, status_code -): +def test_project_list_no_employment(auth_client, is_customer, expected, status_code): ProjectFactory.create_batch(4) project = ProjectFactory.create() - if is_assigned: - project.customer.assignees.add(auth_client.user) + if is_customer: + CustomerAssigneeFactory.create( + user=auth_client.user, is_customer=True, customer=project.customer + ) url = reverse("project-list") diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 8e8281c28..09b328bdb 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -6,7 +6,11 @@ from rest_framework import status from timed.employment.factories import EmploymentFactory -from timed.projects.factories import ProjectFactory, TaskFactory +from timed.projects.factories import ( + CustomerAssigneeFactory, + ProjectFactory, + TaskFactory, +) def test_task_list_not_archived(internal_employee_client, task_factory): @@ -198,16 +202,16 @@ def test_task_list_external_employee(external_employee_client, is_assigned, expe @pytest.mark.parametrize( - "is_assigned, expected, status_code", + "is_customer, expected, status_code", [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], ) -def test_task_list_user_assignee_no_employment( - auth_client, is_assigned, expected, status_code -): +def test_task_list_no_employment(auth_client, is_customer, expected, status_code): TaskFactory.create_batch(4) task = TaskFactory.create() - if is_assigned: - task.project.customer.assignees.add(auth_client.user) + if is_customer: + CustomerAssigneeFactory.create( + user=auth_client.user, is_customer=True, customer=task.project.customer + ) url = reverse("task-list") diff --git a/timed/projects/views.py b/timed/projects/views.py index 3e98c2187..196e5d59a 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -46,7 +46,9 @@ def get_queryset(self): | Q(projects__tasks__assignees=user) ) except Employment.DoesNotExist: - if models.CustomerAssignee.objects.filter(user=user).exists(): + if models.CustomerAssignee.objects.filter( + user=user, is_customer=True + ).exists(): return queryset.filter(Q(assignees=user)) raise PermissionDenied("User has no employment and isn't a customer!") @@ -108,7 +110,9 @@ def get_queryset(self): | Q(customer__assignees=user) ) except Employment.DoesNotExist: - if models.CustomerAssignee.objects.filter(user=user).exists(): + if models.CustomerAssignee.objects.filter( + user=user, is_customer=True + ).exists(): return queryset.filter(Q(customer__assignees=user)) raise PermissionDenied("User has no employment and isn't a customer!") @@ -155,7 +159,9 @@ def get_queryset(self): | Q(project__customer__assignees=user) ) except Employment.DoesNotExist: - if models.CustomerAssignee.objects.filter(user=user).exists(): + if models.CustomerAssignee.objects.filter( + user=user, is_customer=True + ).exists(): return queryset.filter(Q(project__customer__assignees=user)) raise PermissionDenied("User has no employment and isn't a customer!") diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index ebd77dee7..d905c279e 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -6,6 +6,7 @@ CharField, ModelSerializer, SerializerMethodField, + ValidationError, ) from timed.projects.models import Project @@ -79,6 +80,26 @@ class OrderSerializer(ModelSerializer): "project": ("timed.subscription.serializers" ".SubscriptionProjectSerializer") } + def validate(self, data): + """Validate orders. + + Customers and users can not create confirmed orders and can not modify them, + if they're already confirmed. + """ + + user = self.context["request"].user + acknowledged = data.get("acknowledged") + request_method = self.context["request"].method + + if ( + request_method == "POST" + and acknowledged + and not (user.is_accountant or user.is_superuser) + ): + raise ValidationError("User can not create confirmed orders!") + + return data + class Meta: model = Order resource_name = "subscription-orders" diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index efce8bbb3..2df39dc88 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -1,11 +1,36 @@ +import pytest from django.urls import reverse from rest_framework import status +from timed.projects.factories import CustomerAssigneeFactory, ProjectFactory from timed.subscription import factories -def test_order_list(auth_client): - factories.OrderFactory.create() +@pytest.mark.parametrize( + "is_customer, is_accountant, is_superuser", + [ + (True, False, False), + (False, True, False), + (False, False, True), + (False, False, False), + (False, False, False), + ], +) +def test_order_list(auth_client, is_customer, is_accountant, is_superuser): + """Test which user can see orders.""" + order = factories.OrderFactory.create() + user = auth_client.user + + if is_customer: + CustomerAssigneeFactory.create( + customer=order.project.customer, user=user, is_customer=True + ) + elif is_accountant: + user.is_accountant = True + user.save() + elif is_superuser: + user.is_superuser = True + user.save() url = reverse("subscription-order-list") @@ -19,44 +44,186 @@ def test_order_list(auth_client): ) -def test_order_delete(auth_client): - order = factories.OrderFactory.create() +@pytest.mark.parametrize( + "is_customer, is_accountant, is_superuser, confirmed, expected", + [ + (True, False, False, True, status.HTTP_403_FORBIDDEN), + (True, False, False, False, status.HTTP_403_FORBIDDEN), + (False, True, False, True, status.HTTP_403_FORBIDDEN), + (False, True, False, False, status.HTTP_204_NO_CONTENT), + (False, False, True, True, status.HTTP_403_FORBIDDEN), + (False, False, True, False, status.HTTP_204_NO_CONTENT), + (False, False, False, True, status.HTTP_403_FORBIDDEN), + (False, False, False, False, status.HTTP_403_FORBIDDEN), + ], +) +def test_order_delete( + auth_client, is_customer, is_accountant, is_superuser, confirmed, expected +): + """Test which user can delete orders, confirmed or not.""" + order = factories.OrderFactory() + if confirmed: + order.acknowledged = True + order.save() + + user = auth_client.user + + if is_customer: + CustomerAssigneeFactory.create( + customer=order.project.customer, user=user, is_customer=True + ) + elif is_accountant: + user.is_accountant = True + user.save() + elif is_superuser: + user.is_superuser = True + user.save() url = reverse("subscription-order-detail", args=[order.id]) res = auth_client.delete(url) - assert res.status_code == status.HTTP_204_NO_CONTENT - - -def test_order_delete_confirmed(auth_client): - """Deleting of confirmed order should not be possible.""" - order = factories.OrderFactory(acknowledged=True) - - url = reverse("subscription-order-detail", args=[order.id]) - - res = auth_client.delete(url) - assert res.status_code == status.HTTP_403_FORBIDDEN - - -def test_order_confirm_admin(admin_client): - """Test that admin use may confirm order.""" + assert res.status_code == expected + + +@pytest.mark.parametrize( + "is_superuser, is_accountant, is_customer, status_code", + [ + (True, False, False, status.HTTP_204_NO_CONTENT), + (False, True, False, status.HTTP_204_NO_CONTENT), + (False, False, True, status.HTTP_403_FORBIDDEN), + (False, False, False, status.HTTP_403_FORBIDDEN), + ], +) +def test_order_confirm( + auth_client, is_superuser, is_accountant, is_customer, status_code +): + """Test which user may confirm orders.""" order = factories.OrderFactory.create() + user = auth_client.user + + if is_superuser: + user.is_superuser = True + user.save() + elif is_accountant: + user.is_accountant = True + user.save() + elif is_customer: + CustomerAssigneeFactory.create( + user=user, is_customer=True, customer=order.project.customer + ) url = reverse("subscription-order-confirm", args=[order.id]) - res = admin_client.post(url) - assert res.status_code == status.HTTP_204_NO_CONTENT - - order.refresh_from_db() - assert order.acknowledged - assert order.confirmedby == admin_client.user + res = auth_client.post(url) + assert res.status_code == status_code + + if status_code == status.HTTP_204_NO_CONTENT: + order.refresh_from_db() + assert order.acknowledged + assert order.confirmedby == auth_client.user + + +@pytest.mark.parametrize( + "is_customer, is_accountant, is_superuser, acknowledged, expected", + [ + (True, False, False, True, status.HTTP_400_BAD_REQUEST), + (True, False, False, False, status.HTTP_201_CREATED), + (False, True, False, True, status.HTTP_201_CREATED), + (False, True, False, False, status.HTTP_201_CREATED), + (False, False, True, True, status.HTTP_201_CREATED), + (False, False, True, False, status.HTTP_201_CREATED), + (False, False, False, True, status.HTTP_403_FORBIDDEN), + (False, False, False, False, status.HTTP_403_FORBIDDEN), + ], +) +def test_order_create( + auth_client, is_customer, is_accountant, is_superuser, acknowledged, expected +): + """Test which user may create orders. + + Additionally test if for creation of acknowledged/confirmed orders. + """ + user = auth_client.user + project = ProjectFactory.create() + if is_customer: + CustomerAssigneeFactory.create( + user=user, is_customer=True, customer=project.customer + ) + elif is_accountant: + user.is_accountant = True + user.save() + elif is_superuser: + user.is_superuser = True + user.save() + + data = { + "data": { + "type": "subscription-orders", + "id": None, + "attributes": { + "acknowledged": acknowledged, + "duration": "00:30:00", + }, + "relationships": { + "project": { + "data": {"type": "subscription-projects", "id": project.id} + }, + }, + } + } + url = reverse("subscription-order-list") -def test_order_confirm_user(auth_client): - """Test that default user may not confirm order.""" + response = auth_client.post(url, data) + assert response.status_code == expected + + +@pytest.mark.parametrize( + "is_customer, is_accountant, is_superuser, acknowledged, expected", + [ + (True, False, False, True, status.HTTP_403_FORBIDDEN), + (True, False, False, False, status.HTTP_403_FORBIDDEN), + (False, True, False, True, status.HTTP_200_OK), + (False, True, False, False, status.HTTP_200_OK), + (False, False, True, True, status.HTTP_200_OK), + (False, False, True, False, status.HTTP_200_OK), + (False, False, False, True, status.HTTP_403_FORBIDDEN), + (False, False, False, False, status.HTTP_403_FORBIDDEN), + ], +) +def test_order_update( + auth_client, is_customer, is_accountant, is_superuser, acknowledged, expected +): + user = auth_client.user order = factories.OrderFactory.create() - url = reverse("subscription-order-confirm", args=[order.id]) + if acknowledged: + order.acknowledged = True + order.save() + + if is_customer: + CustomerAssigneeFactory.create( + user=user, is_customer=True, customer=order.project.customer + ) + elif is_accountant: + user.is_accountant = True + user.save() + elif is_superuser: + user.is_superuser = True + user.save() + + data = { + "data": { + "type": "subscription-orders", + "id": order.id, + "attributes": { + "duration": "50:00:00", + "acknowledged": True, + }, + } + } - res = auth_client.post(url) - assert res.status_code == status.HTTP_403_FORBIDDEN + url = reverse("subscription-order-detail", args=[order.id]) + + response = auth_client.patch(url, data) + assert response.status_code == expected diff --git a/timed/subscription/views.py b/timed/subscription/views.py index a34e071e0..48ae11bdf 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,13 +1,13 @@ -from rest_framework import ( - decorators, - exceptions, - mixins, - permissions, - response, - status, - viewsets, +from rest_framework import decorators, exceptions, response, status, viewsets + +from timed.permissions import ( + IsAccountant, + IsAuthenticated, + IsCreateOnly, + IsCustomer, + IsReadOnly, + IsSuperUser, ) - from timed.projects.filters import ProjectFilterSet from timed.projects.models import Project @@ -38,20 +38,22 @@ def get_queryset(self): return models.Package.objects.select_related("billing_type") -class OrderViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet, -): +class OrderViewSet(viewsets.ModelViewSet): serializer_class = serializers.OrderSerializer filterset_class = filters.OrderFilter + permission_classes = [ + # superuser and accountants may edit all orders + (IsSuperUser | IsAccountant) + # customers may only create orders + | IsCustomer & IsCreateOnly + # all authenticated users may read all orders + | IsAuthenticated & IsReadOnly + ] @decorators.action( detail=True, methods=["post"], - permission_classes=[permissions.IsAuthenticated, permissions.IsAdminUser], + permission_classes=[IsSuperUser | IsAccountant], ) def confirm(self, request, pk=None): """ @@ -69,9 +71,11 @@ def confirm(self, request, pk=None): def get_queryset(self): return models.Order.objects.select_related("project") - def perform_destroy(self, instance): + def destroy(self, request, pk): + instance = self.get_object() if instance.acknowledged: # acknowledge orders may not be deleted raise exceptions.PermissionDenied() instance.delete() + return response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 7aeddb252..592069957 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -478,7 +478,7 @@ def test_report_detail( "task_assignee__is_reviewer, task_assignee__is_manager, task_assignee__is_resource, is_external, expected", [ (True, False, False, True, status.HTTP_400_BAD_REQUEST), - (False, True, False, True, status.HTTP_400_BAD_REQUEST), + (False, True, False, True, status.HTTP_403_FORBIDDEN), (False, False, True, True, status.HTTP_201_CREATED), (True, False, False, False, status.HTTP_201_CREATED), (False, True, False, False, status.HTTP_201_CREATED), @@ -1200,7 +1200,7 @@ def test_report_export( url = reverse("report-export") - with django_assert_num_queries(3): + with django_assert_num_queries(7): response = internal_employee_client.get(url, data={"file_type": file_type}) assert response.status_code == status.HTTP_200_OK @@ -1650,29 +1650,20 @@ def test_report_list_external_employee(external_employee_client, report_factory) "is_assigned, expected, status_code", [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], ) -def test_report_list_user_assignee_no_employment( +def test_report_list_no_employment( auth_client, report_factory, is_assigned, expected, status_code ): user = auth_client.user report = report_factory.create(user=user, duration=timedelta(hours=1)) if is_assigned: - CustomerAssigneeFactory.create(user=user, customer=report.task.project.customer) + CustomerAssigneeFactory.create( + user=user, is_customer=True, customer=report.task.project.customer + ) report_factory.create_batch(4) url = reverse("report-list") - response = auth_client.get( - url, - data={ - "date": report.date, - "user": user.id, - "task": report.task_id, - "project": report.task.project_id, - "customer": report.task.project.customer_id, - "include": ("user,task,task.project,task.project.customer,verified_by"), - }, - ) - + response = auth_client.get(url) assert response.status_code == status_code json = response.json() diff --git a/timed/tracking/views.py b/timed/tracking/views.py index d15a55b25..23293617d 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -145,9 +145,12 @@ def get_queryset(self): queryset = queryset.filter(Q(task__in=assigned_tasks) | Q(user=user)) return queryset except Employment.DoesNotExist: - if CustomerAssignee.objects.filter(user=user).exists(): + if CustomerAssignee.objects.filter(user=user, is_customer=True).exists(): return queryset.filter( - Q(task__project__customer__customer_assignees__user=user) + Q( + task__project__customer__customer_assignees__user=user, + task__project__customer__customer_assignees__is_customer=True, + ) ) raise exceptions.PermissionDenied( "User has no employment and isn't a customer!" From 1054ad7aa398033e59bb16255115e2b430798466 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Thu, 9 Dec 2021 18:52:59 +0100 Subject: [PATCH 867/980] fix: update fixtures and keycloak config This is especially need for the new customer-center changes --- dev-config/keycloak-config.json | 119 ++++++++++++++++++++++++-------- timed/fixtures/test_data.json | 32 +++++++++ 2 files changed, 123 insertions(+), 28 deletions(-) diff --git a/dev-config/keycloak-config.json b/dev-config/keycloak-config.json index 1c1e5c302..873829e85 100644 --- a/dev-config/keycloak-config.json +++ b/dev-config/keycloak-config.json @@ -55,6 +55,13 @@ "clientRole" : false, "containerId" : "timed", "attributes" : { } + }, { + "id" : "40520b35-6d35-476c-bf26-94dbada179bb", + "name" : "admin", + "composite" : false, + "clientRole" : false, + "containerId" : "timed", + "attributes" : { } } ], "client" : { "realm-management" : [ { @@ -299,7 +306,31 @@ } ] } }, - "groups" : [ ], + "groups" : [ { + "id" : "cfd54499-a335-48e7-bb1e-1d3a6a063d95", + "name" : "access-cc", + "path" : "/access-cc", + "attributes" : { }, + "realmRoles" : [ ], + "clientRoles" : { }, + "subGroups" : [ ] + }, { + "id" : "f9cc25c4-04bb-4c95-aa07-fd918534d2fd", + "name" : "adfinis-users", + "path" : "/adfinis-users", + "attributes" : { }, + "realmRoles" : [ ], + "clientRoles" : { }, + "subGroups" : [ ] + }, { + "id" : "b3cf5ffb-e50f-4a3d-ad4a-8003aa8fd8d0", + "name" : "cc-admin", + "path" : "/cc-admin", + "attributes" : { }, + "realmRoles" : [ ], + "clientRoles" : { }, + "subGroups" : [ ] + } ], "defaultRoles" : [ "offline_access", "uma_authorization" ], "requiredCredentials" : [ "password" ], "otpPolicyType" : "totp", @@ -350,7 +381,7 @@ "account" : [ "view-profile", "manage-account" ] }, "notBefore" : 0, - "groups" : [ ] + "groups" : [ "/cc-admin" ] }, { "id" : "f51ce893-a8a0-444c-aa4e-b09f4e8df4dc", "createdTimestamp" : 1606226312625, @@ -372,7 +403,7 @@ "account" : [ "view-profile", "manage-account" ] }, "notBefore" : 0, - "groups" : [ ] + "groups" : [ "/adfinis-users" ] }, { "id" : "dfabf742-0eff-4699-8d59-311d96afe7a7", "createdTimestamp" : 1606226293084, @@ -394,7 +425,25 @@ "account" : [ "view-profile", "manage-account" ] }, "notBefore" : 0, - "groups" : [ ] + "groups" : [ "/adfinis-users" ] + }, { + "id" : "fddcc94c-011c-47d8-9b01-0f7e651e2f51", + "createdTimestamp" : 1639071866317, + "username" : "wladimirc", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "firstName" : "Wladimir", + "lastName" : "Customer", + "credentials" : [ ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "offline_access", "uma_authorization" ], + "clientRoles" : { + "account" : [ "view-profile", "manage-account" ] + }, + "notBefore" : 0, + "groups" : [ "/access-cc" ] } ], "scopeMappings" : [ { "clientScope" : "offline_access", @@ -642,6 +691,20 @@ "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : -1, + "protocolMappers" : [ { + "id" : "72b658f0-da69-4e56-bf2a-7813e570aba8", + "name" : "Groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-group-membership-mapper", + "consentRequired" : false, + "config" : { + "full.path" : "true", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "userinfo.token.claim" : "true" + } + } ], "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ], "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] } ], @@ -1122,7 +1185,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-address-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-address-mapper" ] } }, { "id" : "04a61e10-fc46-4e0c-ad0a-3eec163d6e24", @@ -1156,7 +1219,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "oidc-address-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-full-name-mapper", "oidc-address-mapper" ] } }, { "id" : "65750361-be2e-41cc-ae63-1abdda285720", @@ -1211,7 +1274,7 @@ "internationalizationEnabled" : false, "supportedLocales" : [ ], "authenticationFlows" : [ { - "id" : "390ed51c-103a-4fce-a941-f69b92d1790e", + "id" : "7d365b56-6fda-4795-8db1-ca66814fbbec", "alias" : "Account verification options", "description" : "Method with which to verity the existing account", "providerId" : "basic-flow", @@ -1231,7 +1294,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "8b10fa7e-6afd-4545-b526-65b2ddb2c558", + "id" : "19ea7c3b-f0d6-4864-a241-e031459ce42c", "alias" : "Authentication Options", "description" : "Authentication options.", "providerId" : "basic-flow", @@ -1257,7 +1320,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "5d151955-5ff5-4117-bcb3-39f96fdb2f2b", + "id" : "93775a29-9b62-4fc8-8287-4431102cd3d7", "alias" : "Browser - Conditional OTP", "description" : "Flow to determine if the OTP is required for the authentication", "providerId" : "basic-flow", @@ -1277,7 +1340,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "94f94d53-1bad-45ba-9b01-04f25c4d4ccc", + "id" : "ca658338-b3c5-4380-a012-b1c460bc3180", "alias" : "Direct Grant - Conditional OTP", "description" : "Flow to determine if the OTP is required for the authentication", "providerId" : "basic-flow", @@ -1297,7 +1360,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "044e6583-5533-48bc-9877-069ae932e1a6", + "id" : "0abce959-cb79-4717-a9d6-cc9894b6c279", "alias" : "First broker login - Conditional OTP", "description" : "Flow to determine if the OTP is required for the authentication", "providerId" : "basic-flow", @@ -1317,7 +1380,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "0813aa63-05e7-4140-9f91-f4fe582ac66b", + "id" : "8b5547f7-6077-4628-9886-f9cc7dfae286", "alias" : "Handle Existing Account", "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", "providerId" : "basic-flow", @@ -1337,7 +1400,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "f057363d-fdce-4a82-8638-c7100db2c443", + "id" : "3be9a096-a01c-483a-a1e0-26cce94b0408", "alias" : "Reset - Conditional OTP", "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", "providerId" : "basic-flow", @@ -1357,7 +1420,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "b55c6e7f-da4f-4e06-bed6-c14e411c309a", + "id" : "30ce88d5-6c70-4619-a341-b31ec4d7404d", "alias" : "User creation or linking", "description" : "Flow for the existing/non-existing user alternatives", "providerId" : "basic-flow", @@ -1378,7 +1441,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "65ea3b1f-9abd-403d-9465-ea7b2c83bf9e", + "id" : "ae7b80e2-92a2-439e-a876-ab54cbafdcd1", "alias" : "Verify Existing Account by Re-authentication", "description" : "Reauthentication of existing account", "providerId" : "basic-flow", @@ -1398,7 +1461,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "f0ac75d5-5bd5-4eb4-8c10-de3422d6f652", + "id" : "3d32657d-a96c-493b-bd88-6eba457755cc", "alias" : "browser", "description" : "browser based authentication", "providerId" : "basic-flow", @@ -1430,7 +1493,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "96919640-961f-4763-b23f-1bb394af0498", + "id" : "efd36136-a562-44a2-993c-4cb2b795df5f", "alias" : "clients", "description" : "Base authentication for clients", "providerId" : "client-flow", @@ -1462,7 +1525,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "34aabf1e-e748-4d2a-8e1b-4e94f77e38c7", + "id" : "42adffb6-c0f9-4f20-a1bf-c372c751a8b4", "alias" : "direct grant", "description" : "OpenID Connect Resource Owner Grant", "providerId" : "basic-flow", @@ -1488,7 +1551,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "5406b7a7-74ac-4c25-ac9d-7b165ef78f23", + "id" : "3c90b3b3-46cc-4172-a5ef-9da549b994af", "alias" : "docker auth", "description" : "Used by Docker clients to authenticate against the IDP", "providerId" : "basic-flow", @@ -1502,7 +1565,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "b73d9942-db8b-4d0f-abd6-643df35e7a80", + "id" : "697c72a6-e218-499c-9101-1061b32c400d", "alias" : "first broker login", "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", "providerId" : "basic-flow", @@ -1523,7 +1586,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "135a219b-9ced-401f-b291-6e43212497c3", + "id" : "524257c8-af12-401c-a91f-f2ca61603c2f", "alias" : "forms", "description" : "Username, password, otp and other auth forms.", "providerId" : "basic-flow", @@ -1543,7 +1606,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "401ae880-d88f-4f76-9469-b5b91c69a15f", + "id" : "c6dfe369-c661-4308-a0ae-b452bb595175", "alias" : "http challenge", "description" : "An authentication flow based on challenge-response HTTP Authentication Schemes", "providerId" : "basic-flow", @@ -1563,7 +1626,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "9c3ff9e5-917a-4f23-8604-f397139bc215", + "id" : "872c96d6-2147-403a-96fe-08d1e36b6c3f", "alias" : "registration", "description" : "registration flow", "providerId" : "basic-flow", @@ -1578,7 +1641,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "4751e2c7-5dde-4131-8301-a1cd488f4215", + "id" : "6bb44f04-e913-4a3c-b245-c9fb7d531a95", "alias" : "registration form", "description" : "registration form", "providerId" : "form-flow", @@ -1610,7 +1673,7 @@ "autheticatorFlow" : false } ] }, { - "id" : "a555a927-b641-470f-b7fe-7251f37bef12", + "id" : "0ca3feab-db41-4856-be2d-305e6c687f72", "alias" : "reset credentials", "description" : "Reset credentials for a user if they forgot their password or something", "providerId" : "basic-flow", @@ -1642,7 +1705,7 @@ "autheticatorFlow" : true } ] }, { - "id" : "b1deb8b2-fb9c-40df-820d-1bd6ba3beb2e", + "id" : "f43b4f94-eb1b-4c77-81b3-ab8ba2c851a8", "alias" : "saml ecp", "description" : "SAML ECP Profile Authentication Flow", "providerId" : "basic-flow", @@ -1657,13 +1720,13 @@ } ] } ], "authenticatorConfig" : [ { - "id" : "908ae980-a792-4181-b04c-3771fb739803", + "id" : "c058c42a-70ae-4d29-8824-b65784892abc", "alias" : "create unique user config", "config" : { "require.password.update.after.registration" : "false" } }, { - "id" : "e4ce89ca-1eb6-4234-803d-0a22359983bb", + "id" : "0982062f-651f-4670-bf88-7808e653af56", "alias" : "review profile config", "config" : { "update.profile.on.first.login" : "missing" diff --git a/timed/fixtures/test_data.json b/timed/fixtures/test_data.json index c9d7f2249..2aa474fda 100644 --- a/timed/fixtures/test_data.json +++ b/timed/fixtures/test_data.json @@ -194,6 +194,26 @@ "supervisors": [] } }, + { + "model": "employment.user", + "pk": 5, + "fields": { + "password": "pbkdf2_sha256$150000$R6spIXkVyNm7$Qg2vsL0klTpgTqRwXm9bu0efHtYM8aAVYsgcXqVJsF0=", + "last_login": null, + "is_superuser": false, + "username": "wladimirc", + "first_name": "Wladimir", + "email": "wladimir@example.com", + "is_staff": false, + "is_active": true, + "date_joined": "2020-03-12T09:28:55Z", + "tour_done": true, + "last_name": "Customer", + "groups": [], + "user_permissions": [], + "supervisors": [] + } + }, { "model": "projects.project", "pk": 1, @@ -341,6 +361,18 @@ "is_manager": false } }, + { + "model": "projects.customerassignee", + "pk": 2, + "fields": { + "user": 5, + "customer": 1, + "is_resource": false, + "is_reviewer": true, + "is_manager": false, + "is_customer": true + } + }, { "model": "projects.taskassignee", "pk": 1, From 0a0d3ae0f0950eca80262aba870ec98ed1cc1f84 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Fri, 10 Dec 2021 13:07:35 +0100 Subject: [PATCH 868/980] fix(docker-compose): add hint for keycloak config export --- docker-compose.override.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a392230b2..aa0e277c5 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -31,7 +31,7 @@ services: keycloak: image: jboss/keycloak:10.0.1 volumes: - - ./dev-config/keycloak-config.json:/etc/keycloak/keycloak-config.json:ro + - ./dev-config/keycloak-config.json:/etc/keycloak/keycloak-config.json:rw depends_on: - db environment: @@ -43,6 +43,8 @@ services: - PROXY_ADDRESS_FORWARDING=true - KEYCLOAK_USER=admin - KEYCLOAK_PASSWORD=admin + # start keycloak with the following command to perform an export of the `timed` realm. + #command: ["-Dkeycloak.migration.action=export", "-Dkeycloak.migration.realmName=timed", "-Dkeycloak.migration.provider=singleFile", "-Dkeycloak.migration.file=/etc/keycloak/keycloak-config.json", "-b", "0.0.0.0"] command: ["-Dkeycloak.migration.action=import", "-Dkeycloak.migration.provider=singleFile", "-Dkeycloak.migration.file=/etc/keycloak/keycloak-config.json", "-b", "0.0.0.0"] networks: - timed.local From 0f8d7b1a4658cf28a1451092733fc73e22a3beaa Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 9 Dec 2021 17:47:20 +0100 Subject: [PATCH 869/980] fix(subscription): fixed visibility on subscription-projects for customers updated tests accordingly --- .../tests/test_subscription_project.py | 78 ++++++++++++++++--- timed/subscription/views.py | 24 +++++- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/timed/subscription/tests/test_subscription_project.py b/timed/subscription/tests/test_subscription_project.py index 4c95a371d..39336eb64 100644 --- a/timed/subscription/tests/test_subscription_project.py +++ b/timed/subscription/tests/test_subscription_project.py @@ -1,10 +1,13 @@ from datetime import timedelta +import pytest from django.urls import reverse -from rest_framework.status import HTTP_200_OK +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND +from timed.employment.factories import EmploymentFactory from timed.projects.factories import ( BillingTypeFactory, + CustomerAssigneeFactory, CustomerFactory, ProjectFactory, TaskFactory, @@ -13,7 +16,12 @@ from timed.tracking.factories import ReportFactory -def test_subscription_project_list(auth_client): +@pytest.mark.parametrize("is_external, expected", [(True, 0), (False, 1)]) +def test_subscription_project_list(auth_client, is_external, expected): + employment = EmploymentFactory.create(user=auth_client.user, is_external=False) + if is_external: + employment.is_external = True + employment.save() customer = CustomerFactory.create() billing_type = BillingTypeFactory() project = ProjectFactory.create( @@ -46,21 +54,69 @@ def test_subscription_project_list(auth_client): assert res.status_code == HTTP_200_OK json = res.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(project.id) + assert len(json["data"]) == expected + if expected: + assert json["data"][0]["id"] == str(project.id) - attrs = json["data"][0]["attributes"] - assert attrs["spent-time"] == "05:00:00" - assert attrs["purchased-time"] == "06:00:00" + attrs = json["data"][0]["attributes"] + assert attrs["spent-time"] == "05:00:00" + assert attrs["purchased-time"] == "06:00:00" -def test_subscription_project_detail(auth_client): +@pytest.mark.parametrize( + "is_customer, project_of_customer, has_employment, is_external, expected", + [ + (True, True, False, False, HTTP_200_OK), + (True, False, False, False, HTTP_404_NOT_FOUND), + (False, False, True, False, HTTP_200_OK), + (False, False, True, True, HTTP_404_NOT_FOUND), + ], +) +def test_subscription_project_detail( + auth_client, is_customer, project_of_customer, has_employment, is_external, expected +): + user = auth_client.user billing_type = BillingTypeFactory() project = ProjectFactory.create(billing_type=billing_type, customer_visible=True) PackageFactory.create_batch(2, billing_type=billing_type) + if has_employment: + employment = EmploymentFactory.create(user=user, is_external=False) + if is_external: + employment.is_external = True + employment.save() + + if is_customer: + customer_assignee = CustomerAssigneeFactory(user=user, is_customer=True) + if project_of_customer: + customer_assignee.customer = project.customer + customer_assignee.save() + url = reverse("subscription-project-detail", args=[project.id]) res = auth_client.get(url) - assert res.status_code == HTTP_200_OK - json = res.json() - assert json["data"]["id"] == str(project.id) + assert res.status_code == expected + + if expected == HTTP_200_OK: + json = res.json() + assert json["data"]["id"] == str(project.id) + + +def test_subscription_project_list_user_is_customer(auth_client): + customer = CustomerFactory.create() + project = ProjectFactory.create(customer=customer, customer_visible=True) + ProjectFactory.create_batch(4, customer_visible=True) + + user = auth_client.user + CustomerAssigneeFactory.create(user=user, customer=customer, is_customer=True) + + url = reverse("subscription-project-list") + + response = auth_client.get(url) + assert response.status_code == HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(project.id) + assert json["data"][0]["relationships"]["customer"]["data"]["id"] == str( + customer.id + ) diff --git a/timed/subscription/views.py b/timed/subscription/views.py index 48ae11bdf..23d7f2dfe 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,5 +1,9 @@ +from datetime import date + +from django.db.models import Q from rest_framework import decorators, exceptions, response, status, viewsets +from timed.employment.models import Employment from timed.permissions import ( IsAccountant, IsAuthenticated, @@ -9,7 +13,7 @@ IsSuperUser, ) from timed.projects.filters import ProjectFilterSet -from timed.projects.models import Project +from timed.projects.models import CustomerAssignee, Project from . import filters, models, serializers @@ -27,7 +31,23 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): ordering_fields = ("name", "id") def get_queryset(self): - return Project.objects.filter(archived=False, customer_visible=True) + user = self.request.user + queryset = Project.objects.filter(archived=False, customer_visible=True) + try: + # check if user is an internal employee + current_employment = Employment.objects.get_at(user=user, date=date.today()) + if not current_employment.is_external: # pragma: no cover + return queryset + except Employment.DoesNotExist: + # if user has no employment, check if he's a customer + if CustomerAssignee.objects.filter(user=user, is_customer=True).exists(): + return queryset.filter( + Q( + customer__customer_assignees__user=user, + customer__customer_assignees__is_customer=True, + ) + ) + return queryset.none() class PackageViewSet(viewsets.ReadOnlyModelViewSet): From 1860d6d8a7039520dc976dca91ac4193870b1ff1 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 7 Dec 2021 16:30:05 +0100 Subject: [PATCH 870/980] fix(oidc): update django user data according to OIDC userinfo --- timed/authentication.py | 10 +++++++++- timed/tests/test_authentication.py | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/timed/authentication.py b/timed/authentication.py index 924ce37fc..5d158e439 100644 --- a/timed/authentication.py +++ b/timed/authentication.py @@ -60,7 +60,9 @@ def get_or_create_user(self, access_token, id_token, payload): users = self.filter_users_by_claims(claims) if len(users) == 1: - return users[0] + user = users.get() + self.update_user_from_claims(user, claims) + return user elif settings.OIDC_CREATE_USER: return self.create_user(claims) else: @@ -71,6 +73,12 @@ def get_or_create_user(self, access_token, id_token, payload): ) return None + def update_user_from_claims(self, user, claims): + user.email = claims.get(settings.OIDC_EMAIL_CLAIM, "") + user.first_name = claims.get(settings.OIDC_FIRSTNAME_CLAIM, "") + user.last_name = claims.get(settings.OIDC_LASTNAME_CLAIM, "") + user.save() + def filter_users_by_claims(self, claims): username = self.get_username(claims) return self.UserModel.objects.filter(username=username) diff --git a/timed/tests/test_authentication.py b/timed/tests/test_authentication.py index d3952ae11..4667555ba 100644 --- a/timed/tests/test_authentication.py +++ b/timed/tests/test_authentication.py @@ -9,6 +9,8 @@ from rest_framework import exceptions, status from rest_framework.exceptions import AuthenticationFailed +from timed.employment.factories import UserFactory + @pytest.mark.parametrize("is_id_token", [True, False]) @pytest.mark.parametrize( @@ -87,6 +89,29 @@ def test_authentication_new_user( assert user_model.objects.count() == expected_count +def test_authentication_update_user_data(db, rf, requests_mock, settings): + user_model = get_user_model() + user = UserFactory.create() + + userinfo = { + "sub": user.username, + "email": "test@localhost", + "given_name": "Max", + "family_name": "Mustermann", + } + + requests_mock.get(settings.OIDC_OP_USER_ENDPOINT, text=json.dumps(userinfo)) + + request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") + + user, _ = OIDCAuthentication().authenticate(request) + + assert user_model.objects.count() == 1 + assert user.first_name == "Max" + assert user.last_name == "Mustermann" + assert user.email == "test@localhost" + + def test_authentication_idp_502(db, rf, requests_mock, settings): requests_mock.get( settings.OIDC_OP_USER_ENDPOINT, status_code=status.HTTP_502_BAD_GATEWAY From ff4a7d9b144582fc87048db26ed0f0c16e2e5643 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 9 Dec 2021 08:34:02 +0100 Subject: [PATCH 871/980] feat(subscription): send mail on order creation send both plain text and html content added email templates moved acknowledged check to OrderView --- timed/settings.py | 4 ++ timed/subscription/notify_admin.py | 53 +++++++++++++++++++ timed/subscription/serializers.py | 21 -------- .../templates/notify_accountants_order.html | 41 ++++++++++++++ .../templates/notify_accountants_order.txt | 14 +++++ timed/subscription/tests/test_order.py | 36 +++++++++---- timed/subscription/views.py | 19 ++++++- 7 files changed, 156 insertions(+), 32 deletions(-) create mode 100644 timed/subscription/notify_admin.py create mode 100644 timed/subscription/templates/notify_accountants_order.html create mode 100644 timed/subscription/templates/notify_accountants_order.txt diff --git a/timed/settings.py b/timed/settings.py index 4e0312a42..02592aa81 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -283,6 +283,10 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DJANGO_DEFAULT_FROM_EMAIL", default("webmaster@localhost") ) +CUSTOMER_CENTER_EMAIL = env.str( + "DJANGO_CUSTOMER_CENTER_EMAIL", default("admin@localhost") +) + SERVER_EMAIL = env.str("DJANGO_SERVER_EMAIL", default("root@localhost")) EMAIL_EXTRA_HEADERS = {"Auto-Submitted": "auto-generated"} diff --git a/timed/subscription/notify_admin.py b/timed/subscription/notify_admin.py new file mode 100644 index 000000000..31c9f59d0 --- /dev/null +++ b/timed/subscription/notify_admin.py @@ -0,0 +1,53 @@ +import datetime + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import get_template, render_to_string + + +def prepare_and_send_email(project, order_duration): + template_txt = get_template("notify_accountants_order.txt") + from_email = settings.DEFAULT_FROM_EMAIL + connection = get_connection() + messages = [] + + customer = project.customer + + duration = datetime.datetime.strptime(order_duration, "%H:%M:%S") + hours_added = datetime.timedelta( + hours=duration.hour, minutes=duration.minute, seconds=duration.second + ) + hours_total = hours_added + project.estimated_time + + subject = f"Customer Center Credits/Reports: {customer.name} has ordered {hours_added} hours." + + body_txt = template_txt.render( + { + "customer": customer, + "project": project, + "hours_added": hours_added, + "hours_total": hours_total, + } + ) + body_html = render_to_string( + "notify_accountants_order.html", + { + "customer": customer, + "project": project, + "hours_added": hours_added, + "hours_total": hours_total, + }, + ) + + message = EmailMultiAlternatives( + subject=subject, + body=body_txt, + from_email=from_email, + to=[settings.CUSTOMER_CENTER_EMAIL], + connection=connection, + headers=settings.EMAIL_EXTRA_HEADERS, + ) + message.attach_alternative(body_html, "text/html") + + messages.append(message) + connection.send_messages(messages) diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index d905c279e..ebd77dee7 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -6,7 +6,6 @@ CharField, ModelSerializer, SerializerMethodField, - ValidationError, ) from timed.projects.models import Project @@ -80,26 +79,6 @@ class OrderSerializer(ModelSerializer): "project": ("timed.subscription.serializers" ".SubscriptionProjectSerializer") } - def validate(self, data): - """Validate orders. - - Customers and users can not create confirmed orders and can not modify them, - if they're already confirmed. - """ - - user = self.context["request"].user - acknowledged = data.get("acknowledged") - request_method = self.context["request"].method - - if ( - request_method == "POST" - and acknowledged - and not (user.is_accountant or user.is_superuser) - ): - raise ValidationError("User can not create confirmed orders!") - - return data - class Meta: model = Order resource_name = "subscription-orders" diff --git a/timed/subscription/templates/notify_accountants_order.html b/timed/subscription/templates/notify_accountants_order.html new file mode 100644 index 000000000..441b17b7b --- /dev/null +++ b/timed/subscription/templates/notify_accountants_order.html @@ -0,0 +1,41 @@ +***EN***
+Charging {{hours_added}} hours + +

+ + + +
+ __________________________________________________________________________

+ +***DE***
+ +Aufladung von {{hours_added}} Stunden + +
    +
  • Kunde: {{customer.name}}
  • +
  • Projekt: {{project.name}}
  • +
  • Projekt Total mit Aufladung: {{hours_total}} Stunden
  • +
+ +
+ __________________________________
+ Im Customer Center anzeigen
+ Kunde anzeigen
+
+ + Customer Center
+ Credits / Reports +
diff --git a/timed/subscription/templates/notify_accountants_order.txt b/timed/subscription/templates/notify_accountants_order.txt new file mode 100644 index 000000000..4f45eb0c8 --- /dev/null +++ b/timed/subscription/templates/notify_accountants_order.txt @@ -0,0 +1,14 @@ +***EN*** + +Customer {{customer.name}} has ordered {{hours_added}} hours for {{project.name}}. +The new project total (if the order is accepted) would be {{hours_total}} hours. + +https://my.adfinis-sygroup.ch/timed-admin/confirm-subscriptions + + +***DE*** + +Kunde {{customer.name}} hat für {{project.name}} {{hours_added}} Stunden bestellt. +Das neue Projekt Total (falls die Bestellung akzeptiert wird) wäre {{hours_total}} Stunden. + +https://my.adfinis-sygroup.ch/timed-admin/confirm-subscriptions \ No newline at end of file diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index 2df39dc88..b54641b1e 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -124,20 +124,27 @@ def test_order_confirm( @pytest.mark.parametrize( - "is_customer, is_accountant, is_superuser, acknowledged, expected", + "is_customer, is_accountant, is_superuser, acknowledged, mail_sent, expected", [ - (True, False, False, True, status.HTTP_400_BAD_REQUEST), - (True, False, False, False, status.HTTP_201_CREATED), - (False, True, False, True, status.HTTP_201_CREATED), - (False, True, False, False, status.HTTP_201_CREATED), - (False, False, True, True, status.HTTP_201_CREATED), - (False, False, True, False, status.HTTP_201_CREATED), - (False, False, False, True, status.HTTP_403_FORBIDDEN), - (False, False, False, False, status.HTTP_403_FORBIDDEN), + (True, False, False, True, 0, status.HTTP_400_BAD_REQUEST), + (True, False, False, False, 1, status.HTTP_201_CREATED), + (False, True, False, True, 1, status.HTTP_201_CREATED), + (False, True, False, False, 1, status.HTTP_201_CREATED), + (False, False, True, True, 1, status.HTTP_201_CREATED), + (False, False, True, False, 1, status.HTTP_201_CREATED), + (False, False, False, True, 0, status.HTTP_403_FORBIDDEN), + (False, False, False, False, 0, status.HTTP_403_FORBIDDEN), ], ) def test_order_create( - auth_client, is_customer, is_accountant, is_superuser, acknowledged, expected + auth_client, + mailoutbox, + is_customer, + is_accountant, + is_superuser, + acknowledged, + mail_sent, + expected, ): """Test which user may create orders. @@ -177,6 +184,15 @@ def test_order_create( response = auth_client.post(url, data) assert response.status_code == expected + assert len(mailoutbox) == mail_sent + if mail_sent: + mail = mailoutbox[0] + url = f"https://my.adfinis-sygroup.ch/timed-admin/{project.id}" + assert str(project.customer) in mail.body + assert str(project.name) in mail.body + assert "0:30:00" in mail.body + assert url in mail.alternatives[0][0] + @pytest.mark.parametrize( "is_customer, is_accountant, is_superuser, acknowledged, expected", diff --git a/timed/subscription/views.py b/timed/subscription/views.py index 23d7f2dfe..d7778ed79 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -2,6 +2,7 @@ from django.db.models import Q from rest_framework import decorators, exceptions, response, status, viewsets +from rest_framework_json_api.serializers import ValidationError from timed.employment.models import Employment from timed.permissions import ( @@ -15,7 +16,7 @@ from timed.projects.filters import ProjectFilterSet from timed.projects.models import CustomerAssignee, Project -from . import filters, models, serializers +from . import filters, models, notify_admin, serializers class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): @@ -70,6 +71,22 @@ class OrderViewSet(viewsets.ModelViewSet): | IsAuthenticated & IsReadOnly ] + def create(self, request, *args, **kwargs): + """Override so we can issue emails on creation.""" + # check if order is acknowledged and created by admin/accountant + if ( + request.method == "POST" + and request.data.get("acknowledged") + and not (request.user.is_accountant or request.user.is_superuser) + ): + raise ValidationError("User can not create confirmed orders!") + + project = Project.objects.get(id=request.data.get("project")["id"]) + order_duration = request.data.get("duration") + + notify_admin.prepare_and_send_email(project, order_duration) + return super().create(request, *args, **kwargs) + @decorators.action( detail=True, methods=["post"], From 13e5f2b89582df23ad27a3442d7c2c2eff1d6048 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 14 Dec 2021 14:36:17 +0100 Subject: [PATCH 872/980] feat(settings): add CORS_ALLOWED_ORIGINS to env --- requirements.txt | 1 + timed/settings.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index e324d1143..58c60f721 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ python-dateutil==2.8.1 django==3.1.7 # might remove this once we find out how the jsonapi extras_require work +django-cors-headers==3.10.1 django-filter==2.4.0 django-multiselectfield==0.1.12 django-prometheus==2.1.0 diff --git a/timed/settings.py b/timed/settings.py index 02592aa81..ef1cf637d 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -64,6 +64,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "djmoney", "mozilla_django_oidc", "django_prometheus", + "corsheaders", "nested_inline", "timed.employment", "timed.projects", @@ -75,6 +76,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): MIDDLEWARE = [ "django_prometheus.middleware.PrometheusBeforeMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -364,3 +366,4 @@ def parse_admins(admins): DATA_UPLOAD_MAX_NUMBER_FIELDS = env.int( "DJANGO_DATA_UPLOAD_MAX_NUMBER_FIELDS", default=1000 ) +CORS_ALLOWED_ORIGINS = env.list("DJANGO_CORS_ALLOWED_ORIGINS", default=[]) From c3a8c6ceb708efd309f79c6f9808231e2169dea4 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Thu, 16 Dec 2021 20:55:18 +0100 Subject: [PATCH 873/980] fix(subscriptions/notify_admin): use dateutils parser to prevent an error Using strptime throws an error if the passed date is over 23 hours. So instead we use dateutils.parser. We also pass the day value to the mail template. --- timed/subscription/notify_admin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/timed/subscription/notify_admin.py b/timed/subscription/notify_admin.py index 31c9f59d0..510754d0b 100644 --- a/timed/subscription/notify_admin.py +++ b/timed/subscription/notify_admin.py @@ -1,5 +1,6 @@ import datetime +from dateutil import parser from django.conf import settings from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import get_template, render_to_string @@ -13,9 +14,12 @@ def prepare_and_send_email(project, order_duration): customer = project.customer - duration = datetime.datetime.strptime(order_duration, "%H:%M:%S") + duration = parser.parse(order_duration) hours_added = datetime.timedelta( - hours=duration.hour, minutes=duration.minute, seconds=duration.second + days=duration.day, + hours=duration.hour, + minutes=duration.minute, + seconds=duration.second, ) hours_total = hours_added + project.estimated_time From 63273d27e9c57714ba9c01c9870a6949cfd33e91 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Fri, 17 Dec 2021 15:26:35 +0100 Subject: [PATCH 874/980] fix(subscription/notify_admin): check project.estimate before calcualting total_hours --- timed/subscription/notify_admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timed/subscription/notify_admin.py b/timed/subscription/notify_admin.py index 510754d0b..cf94e67f8 100644 --- a/timed/subscription/notify_admin.py +++ b/timed/subscription/notify_admin.py @@ -21,7 +21,8 @@ def prepare_and_send_email(project, order_duration): minutes=duration.minute, seconds=duration.second, ) - hours_total = hours_added + project.estimated_time + estimated_time = 0 if project.estimated_time is None else project.estimated_time + hours_total = hours_added + estimated_time subject = f"Customer Center Credits/Reports: {customer.name} has ordered {hours_added} hours." From c7c22edeec03dc4314a2ec09ebbe113cd86b883c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Dec 2021 14:36:21 +0000 Subject: [PATCH 875/980] chore(deps): bump django from 3.1.7 to 3.1.14 Bumps [django](https://github.com/django/django) from 3.1.7 to 3.1.14. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.1.7...3.1.14) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 58c60f721..2db8692f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.1 -django==3.1.7 +django==3.1.14 # might remove this once we find out how the jsonapi extras_require work django-cors-headers==3.10.1 django-filter==2.4.0 From 1d7e45c0df51c09d02e96cb3e55d06a5e243aa1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Dec 2021 14:41:04 +0000 Subject: [PATCH 876/980] chore(deps-dev): bump pytest-django from 4.3.0 to 4.5.2 Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.3.0 to 4.5.2. - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.3.0...v4.5.2) --- updated-dependencies: - dependency-name: pytest-django dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 82cd197b5..55fd46cfd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ isort==5.8.0 pdbpp==0.10.2 pytest==6.2.4 pytest-cov==2.12.1 -pytest-django==4.3.0 +pytest-django==4.5.2 pytest-env==0.6.2 pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 From 1b2dc9544361a986a9390e632fed638ac1f29def Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Dec 2021 14:45:25 +0000 Subject: [PATCH 877/980] chore(deps-dev): bump flake8-isort from 4.0.0 to 4.1.1 Bumps [flake8-isort](https://github.com/gforcada/flake8-isort) from 4.0.0 to 4.1.1. - [Release notes](https://github.com/gforcada/flake8-isort/releases) - [Changelog](https://github.com/gforcada/flake8-isort/blob/master/CHANGES.rst) - [Commits](https://github.com/gforcada/flake8-isort/compare/4.0.0...4.1.1) --- updated-dependencies: - dependency-name: flake8-isort dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 55fd46cfd..03dcb6c36 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ flake8-blind-except==0.2.0 flake8-debugger==4.0.0 flake8-deprecated==1.3 flake8-docstrings==1.6.0 -flake8-isort==4.0.0 +flake8-isort==4.1.1 flake8-string-format==0.3.0 ipdb==0.13.9 isort==5.8.0 From d1add1180fee0da83c71ecea4710cdc407784444 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 17 Dec 2021 15:49:18 +0100 Subject: [PATCH 878/980] chore: relese 1.5.1 also add all the missing changelogs since 1.4.0 --- CHANGELOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++ timed/__init__.py | 2 +- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4240f907..bd108d473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,83 @@ +# v1.5.1 + +### Fix +* **subscription/notify_admin:** Check project.estimate before calcualting total_hours ([`63273d2`](https://github.com/adfinis-sygroup/timed-backend/commit/63273d27e9c57714ba9c01c9870a6949cfd33e91)) +* **subscriptions/notify_admin:** Use dateutils parser to prevent an error ([`c3a8c6c`](https://github.com/adfinis-sygroup/timed-backend/commit/c3a8c6ceb708efd309f79c6f9808231e2169dea4)) + +# v1.5.0 + +### Feat + +* **settings**: add CORS_ALLOWED_ORIGINS to env (9e32bdc58171cbbd24304fb2c30d745d9e2cbe23) + +# v1.4.5 + +### Features + +* Add new `is_customer` assignee role and update permissions #810 +* Update fixtures and keycloak config #813 +* **authentication:** Update django user data according to OIDC userinfo #814 +* **subscription:** Send email on order creation #811 + +### Fixes + +* Fix visibility in various models to not depend on employment #808 +* **subscription:** fix visibility of subscription projects #812 + +# v1.4.4 + +### Features + +* **reports:** Change column for total hours for tasks #800 +* **fixtures:** Add accountant user to fixtures #802 +* **tracking:** Add user to Report Intersection #803 +* **settings:** Make DATA_UPLOAD_MAX_NUMBER_FIELDS alterable #805 + +### Fixes + +* Fix setting correct value for billed flag on projects #799 +* **tracking:** Remove billed check from "editable" filter #804 +* **tracking:** Fix reviewer filter to only show reports in which the user is sole reviewer #807 + +# v1.4.3 + +### Features + +* Use whitenoise to host static files #790 +* Add SECURE_PROXY_SSL_HEADER #785 + +### Fixes + +* Rename IsNotBilledAndVerified permission #796 +* **reports:** Add missing logo and update font in workreport #794 +* **redmine:** Fix total hours calculation #793 + +# v1.4.2 + +### Features + +* Add accountant flag for users #782 +* Add number filter for assignees #780 + +### Fixes + +* Fix calculations in workreport #781 + +# v1.4.1 + +### Fixes + +Add manager role to project assignees #779 + +# v1.4.0 + +### Features +- Serve static files for Django Admin #777 + +### Fixes +- Update fixtures according to new roles #778 + + # v1.3.0 (12 August 2021) ### Feature diff --git a/timed/__init__.py b/timed/__init__.py index c68196d1c..0f228f258 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.5.1" From 645881d22aa7987614a13e7ee62a8f201b60c717 Mon Sep 17 00:00:00 2001 From: Jonas Cosandey Date: Fri, 17 Dec 2021 16:38:56 +0100 Subject: [PATCH 879/980] fix(subscription/notify_admin): prevent invalid addition of datetime and int --- timed/subscription/notify_admin.py | 6 +++-- timed/subscription/tests/test_order.py | 32 ++++++++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/timed/subscription/notify_admin.py b/timed/subscription/notify_admin.py index cf94e67f8..103f04a00 100644 --- a/timed/subscription/notify_admin.py +++ b/timed/subscription/notify_admin.py @@ -21,8 +21,10 @@ def prepare_and_send_email(project, order_duration): minutes=duration.minute, seconds=duration.second, ) - estimated_time = 0 if project.estimated_time is None else project.estimated_time - hours_total = hours_added + estimated_time + + hours_total = hours_added + if project.estimated_time is not None: + hours_total += project.estimated_time subject = f"Customer Center Credits/Reports: {customer.name} has ordered {hours_added} hours." diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index b54641b1e..61434535d 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import pytest from django.urls import reverse from rest_framework import status @@ -124,16 +126,24 @@ def test_order_confirm( @pytest.mark.parametrize( - "is_customer, is_accountant, is_superuser, acknowledged, mail_sent, expected", + "is_customer, is_accountant, is_superuser, acknowledged, mail_sent, project_estimate, expected", [ - (True, False, False, True, 0, status.HTTP_400_BAD_REQUEST), - (True, False, False, False, 1, status.HTTP_201_CREATED), - (False, True, False, True, 1, status.HTTP_201_CREATED), - (False, True, False, False, 1, status.HTTP_201_CREATED), - (False, False, True, True, 1, status.HTTP_201_CREATED), - (False, False, True, False, 1, status.HTTP_201_CREATED), - (False, False, False, True, 0, status.HTTP_403_FORBIDDEN), - (False, False, False, False, 0, status.HTTP_403_FORBIDDEN), + ( + True, + False, + False, + True, + 0, + timedelta(minutes=1), + status.HTTP_400_BAD_REQUEST, + ), + (True, False, False, False, 1, timedelta(hours=1), status.HTTP_201_CREATED), + (False, True, False, True, 1, timedelta(hours=10), status.HTTP_201_CREATED), + (False, True, False, False, 1, timedelta(hours=24), status.HTTP_201_CREATED), + (False, False, True, True, 1, timedelta(hours=50), status.HTTP_201_CREATED), + (False, False, True, False, 1, timedelta(hours=100), status.HTTP_201_CREATED), + (False, False, False, True, 0, timedelta(hours=200), status.HTTP_403_FORBIDDEN), + (False, False, False, False, 0, None, status.HTTP_403_FORBIDDEN), ], ) def test_order_create( @@ -144,6 +154,7 @@ def test_order_create( is_superuser, acknowledged, mail_sent, + project_estimate, expected, ): """Test which user may create orders. @@ -151,7 +162,8 @@ def test_order_create( Additionally test if for creation of acknowledged/confirmed orders. """ user = auth_client.user - project = ProjectFactory.create() + project = ProjectFactory.create(estimated_time=project_estimate) + if is_customer: CustomerAssigneeFactory.create( user=user, is_customer=True, customer=project.customer From 83ded8256787dfbb0e748154aa1ffa000c85591e Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Mon, 20 Dec 2021 14:51:41 +0100 Subject: [PATCH 880/980] chore: release v1.5.2 --- CHANGELOG.md | 5 +++++ timed/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd108d473..d8ffbe740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v1.5.2 + +### Fix +* **subscription/notify_admin:** Prevent invalid addition of datetime and int ([`645881d`](https://github.com/adfinis-sygroup/timed-backend/pull/829/commits/645881d22aa7987614a13e7ee62a8f201b60c717)) + # v1.5.1 ### Fix diff --git a/timed/__init__.py b/timed/__init__.py index 0f228f258..5197c5f5a 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "1.5.1" +__version__ = "1.5.2" From a8210651a575ad3eab94ee54ee3c874655de3e7a Mon Sep 17 00:00:00 2001 From: David Vogt Date: Mon, 27 Dec 2021 17:46:06 +0100 Subject: [PATCH 881/980] fix(filters): make date filter on employments work across timezones The date filter on the employments assumed that an unbounded employment ends today, to simplify the generated SQL. However, this breaks when the user is ahead of the server's timezone (for example, a user in Australia requests his employment as of 2021-12-28, where in the server's UTC timezone, it's still 2021-12-27 - the user won't receive an employment) The filter now correctly checks that either the employment is ending today or later, or is explicitly unbounded. --- timed/employment/filters.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index d57202320..56cc531d1 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,7 +1,4 @@ -from datetime import date - -from django.db.models import Value -from django.db.models.functions import Coalesce +from django.db.models import Q from django_filters.constants import EMPTY_VALUES from django_filters.rest_framework import DateFilter, Filter, FilterSet, NumberFilter @@ -75,9 +72,11 @@ class EmploymentFilterSet(FilterSet): date = DateFilter(method="filter_date") def filter_date(self, queryset, name, value): - queryset = queryset.annotate(end=Coalesce("end_date", Value(date.today()))) - queryset = queryset.filter(start_date__lte=value, end__gte=value) + queryset = queryset.filter( + Q(start_date__lte=value) + & Q(Q(end_date__gte=value) | Q(end_date__isnull=True)) + ) return queryset From 3acb9c67c99df6156b0ea49629ababe7f259cded Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Dec 2021 09:05:07 +0000 Subject: [PATCH 882/980] chore(deps): bump mozilla-django-oidc from 1.2.4 to 2.0.0 Bumps [mozilla-django-oidc](https://github.com/mozilla/mozilla-django-oidc) from 1.2.4 to 2.0.0. - [Release notes](https://github.com/mozilla/mozilla-django-oidc/releases) - [Changelog](https://github.com/mozilla/mozilla-django-oidc/blob/master/HISTORY.rst) - [Commits](https://github.com/mozilla/mozilla-django-oidc/compare/1.2.4...2.0.0) --- updated-dependencies: - dependency-name: mozilla-django-oidc dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2db8692f6..5ac3c30ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-multiselectfield==0.1.12 django-prometheus==2.1.0 djangorestframework==3.12.4 djangorestframework-jsonapi[django-filter]==3.1.0 -mozilla-django-oidc==1.2.4 +mozilla-django-oidc==2.0.0 psycopg2==2.8.6 pytz==2021.1 pyexcel-webio==0.1.4 From 1db37f6de83095357594cd4d24b449a51e8d9499 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Dec 2021 09:09:57 +0000 Subject: [PATCH 883/980] chore(deps): bump django-nested-inline from 0.4.2 to 0.4.4 Bumps [django-nested-inline](https://github.com/s-block/django-nested-inline) from 0.4.2 to 0.4.4. - [Release notes](https://github.com/s-block/django-nested-inline/releases) - [Commits](https://github.com/s-block/django-nested-inline/commits) --- updated-dependencies: - dependency-name: django-nested-inline dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5ac3c30ea..007910c3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ pytz==2021.1 pyexcel-webio==0.1.4 pyexcel-io==0.6.4 django-excel==0.0.10 -django-nested-inline==0.4.2 +django-nested-inline==0.4.4 pyexcel-ods3==0.6.0 pyexcel-xlsx==0.6.0 pyexcel-ezodf==0.3.4 From 6f8f477c4f10b2b042577e5d2a0e4e8cc1ede9a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Dec 2021 09:15:00 +0000 Subject: [PATCH 884/980] chore(deps): bump sentry-sdk from 1.0.0 to 1.5.1 Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.0.0 to 1.5.1. - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/1.0.0...1.5.1) --- updated-dependencies: - dependency-name: sentry-sdk dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 007910c3a..bf7980259 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,6 @@ pyexcel-ezodf==0.3.4 django-environ==0.4.5 django-money==1.3.1 python-redmine==2.3.0 -sentry-sdk==1.0.0 +sentry-sdk==1.5.1 gunicorn==20.1.0 whitenoise==5.3.0 From cdb230c202c83c4107b9d9e36e19c5f2825f85c3 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 28 Dec 2021 15:17:12 +0100 Subject: [PATCH 885/980] fix(reports): change total hours column in workreport --- timed/reports/templates/workreport.ots | Bin 159792 -> 159982 bytes timed/reports/views.py | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index 7c4a9e68e8adb4ddb2c31cec3ab37254a9c41f3b..94eff7db531d8123333893efc82db4984923a321 100644 GIT binary patch delta 11943 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 Q6g+z%3KXO@@So`a2Y>&9v;Y7A delta 11664 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 bk5`uRk6eNEf05E`Km&hR&{B5bfAaqae>vu@ diff --git a/timed/reports/views.py b/timed/reports/views.py index 8f65ffcbf..8050c982f 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -285,18 +285,18 @@ def _create_workreport(self, from_date, to_date, today, project, reports, user): table.insert_rows(pos, 1) table.row_info(pos).style_name = table.row_info(pos - 1).style_name table[pos, 0] = Cell(task_name, style_name=table[pos - 1, 0].style_name) - table[pos, 4] = Cell( + table[pos, 2] = Cell( task_total_hours, style_name=table[pos - 1, 2].style_name ) # calculate location of total hours as insert rows moved it - table[13 + len(reports) + len(tasks), 4].formula = "of:=SUM(C13:C{0})".format( + table[13 + len(reports) + len(tasks), 2].formula = "of:=SUM(C13:C{0})".format( str(13 + len(reports) - 1) ) # calculate location of total not billable hours as insert rows moved it table[ - 13 + len(reports) + len(tasks) + 1, 4 + 13 + len(reports) + len(tasks) + 1, 2 ].formula = 'of:=SUMIF(F13:F{0};"no";C13:C{0})'.format( str(13 + len(reports) - 1) ) From fa4f3a6db6f51f1ba1f0336dcb8ca394d9e53c59 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 21 Dec 2021 16:33:53 +0100 Subject: [PATCH 886/980] fix(visibility): restrict visibilty for customers fix IsInternal and IsExternal permission added get_active_employment method in user model added method for setting up customer and employment status --- timed/conftest.py | 13 ++ timed/employment/models.py | 12 ++ timed/employment/tests/test_absence_type.py | 49 +++++-- timed/employment/tests/test_location.py | 52 ++++++-- timed/employment/tests/test_public_holiday.py | 48 +++++-- timed/employment/views.py | 30 ++++- timed/permissions.py | 28 +--- timed/projects/factories.py | 1 + timed/projects/tests/test_billing_type.py | 37 +++++- timed/projects/tests/test_cost_center.py | 38 +++++- timed/projects/tests/test_customer.py | 13 +- .../projects/tests/test_customer_assignee.py | 40 +++++- timed/projects/tests/test_project.py | 19 +-- timed/projects/tests/test_project_assignee.py | 40 +++++- timed/projects/tests/test_task.py | 21 ++- timed/projects/tests/test_task_assignee.py | 40 +++++- timed/projects/views.py | 111 +++++++++------- .../reports/tests/test_customer_statistic.py | 121 ++++++++++++------ timed/reports/tests/test_month_statistic.py | 63 ++++++--- timed/reports/tests/test_project_statistic.py | 87 ++++++++----- timed/reports/tests/test_task_statistic.py | 79 ++++++++---- timed/reports/tests/test_user_statistic.py | 74 +++++++---- timed/reports/tests/test_work_report.py | 101 ++++++++++----- timed/reports/tests/test_year_statistic.py | 89 +++++++++---- timed/reports/views.py | 15 ++- timed/subscription/views.py | 15 +-- 26 files changed, 870 insertions(+), 366 deletions(-) diff --git a/timed/conftest.py b/timed/conftest.py index 5139edadc..2f431b5c7 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -142,3 +142,16 @@ def internal_employee_client(internal_employee): @pytest.fixture(scope="function", autouse=True) def _autoclear_cache(): cache.clear() + + +def setup_customer_and_employment_status( + user, is_assignee, is_customer, is_employed, is_external +): + if is_assignee: + projects_factories.CustomerAssigneeFactory.create( + user=user, is_customer=is_customer + ) + if is_employed: + employment_factories.EmploymentFactory.create( + user=user, is_external=is_external + ) diff --git a/timed/employment/models.py b/timed/employment/models.py index 80b8b2361..02d911391 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -402,3 +402,15 @@ def calculate_worktime(self, start, end): balance = sum([balance[2] for balance in balances], timedelta()) return (reported, expected, balance) + + def get_active_employment(self): + """Get current employment of the user. + + Get current active employment of the user. + If the user doesn't have a return None. + """ + try: + current_employment = Employment.objects.get_at(user=self, date=date.today()) + return current_employment + except Employment.DoesNotExist: + return None diff --git a/timed/employment/tests/test_absence_type.py b/timed/employment/tests/test_absence_type.py index 98d3d3e9e..ad6811227 100644 --- a/timed/employment/tests/test_absence_type.py +++ b/timed/employment/tests/test_absence_type.py @@ -1,28 +1,48 @@ +import pytest from django.urls import reverse from rest_framework import status -from timed.employment.factories import AbsenceTypeFactory - - -def test_absence_type_list(auth_client): +from timed.conftest import setup_customer_and_employment_status +from timed.employment.factories import AbsenceTypeFactory, EmploymentFactory + + +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected", + [ + (False, True, True, 0), + (False, True, False, 0), + (True, False, False, 2), + (True, True, False, 2), + (True, True, True, 2), + ], +) +def test_absence_type_list( + auth_client, is_employed, is_customer_assignee, is_customer, expected +): + setup_customer_and_employment_status( + user=auth_client.user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) AbsenceTypeFactory.create_batch(2) - url = reverse("absence-type-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json["data"]) == 2 + assert len(json["data"]) == expected -def test_absence_type_list_filter_fill_worktime(auth_client): +def test_absence_type_list_filter_fill_worktime(internal_employee_client): absence_type = AbsenceTypeFactory.create(fill_worktime=True) AbsenceTypeFactory.create() url = reverse("absence-type-list") - response = auth_client.get(url, data={"fill_worktime": 1}) + response = internal_employee_client.get(url, data={"fill_worktime": 1}) assert response.status_code == status.HTTP_200_OK json = response.json() @@ -30,14 +50,23 @@ def test_absence_type_list_filter_fill_worktime(auth_client): assert json["data"][0]["id"] == str(absence_type.id) -def test_absence_type_detail(auth_client): +@pytest.mark.parametrize( + "is_employed, expected", + [ + (True, status.HTTP_200_OK), + (False, status.HTTP_404_NOT_FOUND), + ], +) +def test_absence_type_detail(auth_client, is_employed, expected): absence_type = AbsenceTypeFactory.create() + if is_employed: + EmploymentFactory.create(user=auth_client.user) url = reverse("absence-type-detail", args=[absence_type.id]) response = auth_client.get(url) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == expected def test_absence_type_create(auth_client): diff --git a/timed/employment/tests/test_location.py b/timed/employment/tests/test_location.py index 865935c9a..5685afda6 100644 --- a/timed/employment/tests/test_location.py +++ b/timed/employment/tests/test_location.py @@ -1,28 +1,58 @@ +import pytest from django.urls import reverse from rest_framework import status -from timed.employment.factories import LocationFactory - - -def test_location_list(auth_client): - LocationFactory.create() +from timed.conftest import setup_customer_and_employment_status +from timed.employment.factories import EmploymentFactory, LocationFactory + + +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected", + [ + (False, True, True, 0), + (False, True, False, 0), + (True, True, True, 2), + (True, True, False, 2), + (True, False, False, 2), + ], +) +def test_location_list( + auth_client, is_employed, is_customer_assignee, is_customer, expected, location +): + setup_customer_and_employment_status( + user=auth_client.user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) url = reverse("location-list") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] - assert len(data) == 1 - assert data[0]["attributes"]["workdays"] == ([str(day) for day in range(1, 6)]) - - -def test_location_detail(auth_client): + assert len(data) == expected + if expected: + assert data[0]["attributes"]["workdays"] == ([str(day) for day in range(1, 6)]) + + +@pytest.mark.parametrize( + "is_employed, expected", + [ + (True, status.HTTP_200_OK), + (False, status.HTTP_404_NOT_FOUND), + ], +) +def test_location_detail(auth_client, is_employed, expected): location = LocationFactory.create() + if is_employed: + EmploymentFactory.create(user=auth_client.user) url = reverse("location-detail", args=[location.id]) response = auth_client.get(url) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == expected def test_location_create(auth_client): diff --git a/timed/employment/tests/test_public_holiday.py b/timed/employment/tests/test_public_holiday.py index 299bed043..033252876 100644 --- a/timed/employment/tests/test_public_holiday.py +++ b/timed/employment/tests/test_public_holiday.py @@ -1,12 +1,33 @@ from datetime import date +import pytest from django.urls import reverse from rest_framework import status -from timed.employment.factories import PublicHolidayFactory - - -def test_public_holiday_list(auth_client): +from timed.conftest import setup_customer_and_employment_status +from timed.employment.factories import EmploymentFactory, PublicHolidayFactory + + +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected", + [ + (False, True, True, 0), + (False, True, False, 0), + (True, False, False, 1), + (True, True, False, 1), + (True, True, True, 1), + ], +) +def test_public_holiday_list( + auth_client, is_employed, is_customer_assignee, is_customer, expected +): + setup_customer_and_employment_status( + user=auth_client.user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) PublicHolidayFactory.create() url = reverse("public-holiday-list") @@ -14,16 +35,25 @@ def test_public_holiday_list(auth_client): assert response.status_code == status.HTTP_200_OK json = response.json() - assert len(json["data"]) == 1 + assert len(json["data"]) == expected -def test_public_holiday_detail(auth_client): +@pytest.mark.parametrize( + "is_employed, expected", + [ + (True, status.HTTP_200_OK), + (False, status.HTTP_404_NOT_FOUND), + ], +) +def test_public_holiday_detail(auth_client, is_employed, expected): public_holiday = PublicHolidayFactory.create() + if is_employed: + EmploymentFactory.create(user=auth_client.user) url = reverse("public-holiday-detail", args=[public_holiday.id]) response = auth_client.get(url) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == expected def test_public_holiday_create(auth_client): @@ -51,13 +81,13 @@ def test_public_holiday_delete(auth_client): assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED -def test_public_holiday_year_filter(auth_client): +def test_public_holiday_year_filter(internal_employee_client): PublicHolidayFactory.create(date=date(2017, 1, 1)) public_holiday = PublicHolidayFactory.create(date=date(2018, 1, 1)) url = reverse("public-holiday-list") - response = auth_client.get(url, data={"year": 2018}) + response = internal_employee_client.get(url, data={"year": 2018}) assert response.status_code == status.HTTP_200_OK json = response.json() diff --git a/timed/employment/views.py b/timed/employment/views.py index 01d786e14..e48ceacac 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -345,6 +345,16 @@ class LocationViewSet(ReadOnlyModelViewSet): serializer_class = serializers.LocationSerializer ordering = ("name",) + def get_queryset(self): + """Don't show locations to customers.""" + user = self.request.user + + queryset = models.Location.objects.all() + + if user.get_active_employment(): + return queryset + return queryset.none() + class PublicHolidayViewSet(ReadOnlyModelViewSet): """Public holiday view set.""" @@ -356,10 +366,18 @@ class PublicHolidayViewSet(ReadOnlyModelViewSet): def get_queryset(self): """Prefetch the related data. + Don't show public holidays to customers. + :return: The public holidays :rtype: QuerySet """ - return models.PublicHoliday.objects.select_related("location") + user = self.request.user + + queryset = models.PublicHoliday.objects.select_related("location") + + if user.get_active_employment(): + return queryset + return queryset.none() class AbsenceTypeViewSet(ReadOnlyModelViewSet): @@ -370,6 +388,16 @@ class AbsenceTypeViewSet(ReadOnlyModelViewSet): filterset_class = filters.AbsenceTypeFilterSet ordering = ("name",) + def get_queryset(self): + """Don't show absence types to customers.""" + user = self.request.user + + queryset = models.AbsenceType.objects.all() + + if user.get_active_employment(): + return queryset + return queryset.none() + class AbsenceCreditViewSet(ModelViewSet): """Absence type view set.""" diff --git a/timed/permissions.py b/timed/permissions.py index 1688ef25d..90d9d129e 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -173,18 +173,10 @@ def has_permission(self, request, view): if not super().has_permission(request, view): # pragma: no cover return False - try: - employment = employment_models.Employment.objects.get_at( - user=request.user, date=date.today() - ) + employment = request.user.get_active_employment() + if employment: return not employment.is_external - except employment_models.Employment.DoesNotExist: - # if the user has no employment, check if he's a customer - if projects_models.CustomerAssignee.objects.filter( - user=request.user, is_customer=True - ).exists(): - return True - return False + return False def has_object_permission(self, request, view, obj): if not super().has_object_permission(request, view, obj): # pragma: no cover @@ -205,18 +197,10 @@ def has_permission(self, request, view): if not super().has_permission(request, view): # pragma: no cover return False - try: - employment = employment_models.Employment.objects.get_at( - user=request.user, date=date.today() - ) + employment = request.user.get_active_employment() + if employment: return employment.is_external - except employment_models.Employment.DoesNotExist: # pragma: no cover - # if the user has no employment, check if he's a customer - if projects_models.CustomerAssignee.objects.filter( - user=request.user, is_customer=True - ).exists(): - return True - return False + return False def has_object_permission(self, request, view, obj): if not super().has_object_permission(request, view, obj): # pragma: no cover diff --git a/timed/projects/factories.py b/timed/projects/factories.py index 8c751a4c8..e18e0820c 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -44,6 +44,7 @@ class ProjectFactory(DjangoModelFactory): estimated_time = Faker("time_delta") archived = False billed = False + customer_visible = False comment = Faker("sentence") customer = SubFactory("timed.projects.factories.CustomerFactory") cost_center = SubFactory("timed.projects.factories.CostCenterFactory") diff --git a/timed/projects/tests/test_billing_type.py b/timed/projects/tests/test_billing_type.py index 001ff0775..6deb8f52b 100644 --- a/timed/projects/tests/test_billing_type.py +++ b/timed/projects/tests/test_billing_type.py @@ -1,15 +1,38 @@ +import pytest from django.urls import reverse -from rest_framework.status import HTTP_200_OK +from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN +from timed.conftest import setup_customer_and_employment_status from timed.projects.factories import BillingTypeFactory -def test_billing_type_list(internal_employee_client): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, status_code", + [ + (False, True, False, HTTP_403_FORBIDDEN), + (False, True, True, HTTP_403_FORBIDDEN), + (True, False, False, HTTP_200_OK), + (True, True, False, HTTP_200_OK), + (True, True, True, HTTP_200_OK), + ], +) +def test_billing_type_list( + auth_client, is_employed, is_customer_assignee, is_customer, status_code +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) billing_type = BillingTypeFactory.create() url = reverse("billing-type-list") - res = internal_employee_client.get(url) - assert res.status_code == HTTP_200_OK - json = res.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(billing_type.id) + res = auth_client.get(url) + assert res.status_code == status_code + if res.status_code == HTTP_200_OK: + json = res.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(billing_type.id) diff --git a/timed/projects/tests/test_cost_center.py b/timed/projects/tests/test_cost_center.py index 2510e8893..08d6422ed 100644 --- a/timed/projects/tests/test_cost_center.py +++ b/timed/projects/tests/test_cost_center.py @@ -1,15 +1,39 @@ +import pytest from django.urls import reverse -from rest_framework.status import HTTP_200_OK +from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN +from timed.conftest import setup_customer_and_employment_status from timed.projects.factories import CostCenterFactory -def test_cost_center_list(internal_employee_client): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, status_code", + [ + (False, True, False, HTTP_403_FORBIDDEN), + (False, True, True, HTTP_403_FORBIDDEN), + (True, False, False, HTTP_200_OK), + (True, True, False, HTTP_200_OK), + (True, True, True, HTTP_200_OK), + ], +) +def test_cost_center_list( + auth_client, is_employed, is_customer_assignee, is_customer, status_code +): + user = auth_client.user cost_center = CostCenterFactory.create() + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) + url = reverse("cost-center-list") - res = internal_employee_client.get(url) - assert res.status_code == HTTP_200_OK - json = res.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(cost_center.id) + res = auth_client.get(url) + assert res.status_code == status_code + if res.status_code == HTTP_200_OK: + json = res.json() + assert len(json["data"]) == 1 + assert json["data"][0]["id"] == str(cost_center.id) diff --git a/timed/projects/tests/test_customer.py b/timed/projects/tests/test_customer.py index b71a2ffbd..6a901a806 100644 --- a/timed/projects/tests/test_customer.py +++ b/timed/projects/tests/test_customer.py @@ -74,10 +74,10 @@ def test_customer_list_external_employee( @pytest.mark.parametrize( - "is_customer, expected, status_code", - [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], + "is_customer, expected", + [(True, 1), (False, 0)], ) -def test_customer_list_no_employment(auth_client, is_customer, expected, status_code): +def test_customer_list_no_employment(auth_client, is_customer, expected): CustomerFactory.create_batch(4) customer = CustomerFactory.create() if is_customer: @@ -88,8 +88,7 @@ def test_customer_list_no_employment(auth_client, is_customer, expected, status_ url = reverse("customer-list") response = auth_client.get(url) - assert response.status_code == status_code + assert response.status_code == status.HTTP_200_OK - if expected: - json = response.json() - assert len(json["data"]) == expected + json = response.json() + assert len(json["data"]) == expected diff --git a/timed/projects/tests/test_customer_assignee.py b/timed/projects/tests/test_customer_assignee.py index b3901bdd9..73c7232d3 100644 --- a/timed/projects/tests/test_customer_assignee.py +++ b/timed/projects/tests/test_customer_assignee.py @@ -1,18 +1,44 @@ +import pytest from django.urls import reverse from rest_framework.status import HTTP_200_OK +from timed.conftest import setup_customer_and_employment_status from timed.projects.factories import CustomerAssigneeFactory -def test_customer_assignee_list(internal_employee_client): +@pytest.mark.parametrize( + "is_employed, is_external, is_customer_assignee, is_customer, expected", + [ + (False, False, True, False, 0), + (False, False, True, True, 0), + (True, True, False, False, 0), + (True, False, False, False, 1), + (True, True, True, False, 0), + (True, False, True, False, 2), + (True, True, True, True, 0), + (True, False, True, True, 2), + ], +) +def test_customer_assignee_list( + auth_client, is_employed, is_external, is_customer_assignee, is_customer, expected +): customer_assignee = CustomerAssigneeFactory.create() + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=is_external, + ) url = reverse("customer-assignee-list") - res = internal_employee_client.get(url) + res = auth_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(customer_assignee.id) - assert json["data"][0]["relationships"]["customer"]["data"]["id"] == str( - customer_assignee.customer.id - ) + assert len(json["data"]) == expected + if expected: + assert json["data"][0]["id"] == str(customer_assignee.id) + assert json["data"][0]["relationships"]["customer"]["data"]["id"] == str( + customer_assignee.customer.id + ) diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 640f364b2..5914692f5 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -171,12 +171,16 @@ def test_project_update_billed_flag(internal_employee_client, report_factory): @pytest.mark.parametrize( - "is_customer, expected, status_code", - [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], + "is_customer, project__customer_visible, expected", + [ + (True, True, 1), + (True, False, 0), + (False, True, 0), + (False, False, 0), + ], ) -def test_project_list_no_employment(auth_client, is_customer, expected, status_code): +def test_project_list_no_employment(auth_client, project, is_customer, expected): ProjectFactory.create_batch(4) - project = ProjectFactory.create() if is_customer: CustomerAssigneeFactory.create( user=auth_client.user, is_customer=True, customer=project.customer @@ -185,8 +189,7 @@ def test_project_list_no_employment(auth_client, is_customer, expected, status_c url = reverse("project-list") response = auth_client.get(url) - assert response.status_code == status_code + assert response.status_code == status.HTTP_200_OK - if expected: - json = response.json() - assert len(json["data"]) == expected + json = response.json() + assert len(json["data"]) == expected diff --git a/timed/projects/tests/test_project_assignee.py b/timed/projects/tests/test_project_assignee.py index e6c23e700..6fc5eec22 100644 --- a/timed/projects/tests/test_project_assignee.py +++ b/timed/projects/tests/test_project_assignee.py @@ -1,18 +1,44 @@ +import pytest from django.urls import reverse from rest_framework.status import HTTP_200_OK +from timed.conftest import setup_customer_and_employment_status from timed.projects.factories import ProjectAssigneeFactory -def test_project_assignee_list(internal_employee_client): +@pytest.mark.parametrize( + "is_employed, is_external, is_customer_assignee, is_customer, expected", + [ + (False, False, True, False, 0), + (False, False, True, True, 0), + (True, True, False, False, 0), + (True, False, False, False, 1), + (True, True, True, False, 0), + (True, False, True, False, 1), + (True, True, True, True, 0), + (True, False, True, True, 1), + ], +) +def test_project_assignee_list( + auth_client, is_employed, is_external, is_customer_assignee, is_customer, expected +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=is_external, + ) project_assignee = ProjectAssigneeFactory.create() url = reverse("project-assignee-list") - res = internal_employee_client.get(url) + res = auth_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(project_assignee.id) - assert json["data"][0]["relationships"]["project"]["data"]["id"] == str( - project_assignee.project.id - ) + assert len(json["data"]) == expected + if expected: + assert json["data"][0]["id"] == str(project_assignee.id) + assert json["data"][0]["relationships"]["project"]["data"]["id"] == str( + project_assignee.project.id + ) diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 09b328bdb..69d7922b1 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -202,22 +202,29 @@ def test_task_list_external_employee(external_employee_client, is_assigned, expe @pytest.mark.parametrize( - "is_customer, expected, status_code", - [(True, 1, status.HTTP_200_OK), (False, 0, status.HTTP_403_FORBIDDEN)], + "is_customer, customer_visible, expected", + [ + (True, True, 1), + (True, False, 0), + (False, False, 0), + (False, True, 0), + ], ) -def test_task_list_no_employment(auth_client, is_customer, expected, status_code): +def test_task_list_no_employment(auth_client, is_customer, customer_visible, expected): TaskFactory.create_batch(4) task = TaskFactory.create() if is_customer: CustomerAssigneeFactory.create( user=auth_client.user, is_customer=True, customer=task.project.customer ) + if customer_visible: + task.project.customer_visible = True + task.project.save() url = reverse("task-list") response = auth_client.get(url) - assert response.status_code == status_code + assert response.status_code == status.HTTP_200_OK - if expected: - json = response.json() - assert len(json["data"]) == expected + json = response.json() + assert len(json["data"]) == expected diff --git a/timed/projects/tests/test_task_assignee.py b/timed/projects/tests/test_task_assignee.py index 919c7c55b..522350e2a 100644 --- a/timed/projects/tests/test_task_assignee.py +++ b/timed/projects/tests/test_task_assignee.py @@ -1,18 +1,44 @@ +import pytest from django.urls import reverse from rest_framework.status import HTTP_200_OK +from timed.conftest import setup_customer_and_employment_status from timed.projects.factories import TaskAssigneeFactory -def test_task_assignee_list(internal_employee_client): +@pytest.mark.parametrize( + "is_employed, is_external, is_customer_assignee, is_customer, expected", + [ + (False, False, True, False, 0), + (False, False, True, True, 0), + (True, True, False, False, 0), + (True, False, False, False, 1), + (True, True, True, False, 0), + (True, False, True, False, 1), + (True, True, True, True, 0), + (True, False, True, True, 1), + ], +) +def test_task_assignee_list( + auth_client, is_employed, is_external, is_customer_assignee, is_customer, expected +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=is_external, + ) task_assignee = TaskAssigneeFactory.create() url = reverse("task-assignee-list") - res = internal_employee_client.get(url) + res = auth_client.get(url) assert res.status_code == HTTP_200_OK json = res.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(task_assignee.id) - assert json["data"][0]["relationships"]["task"]["data"]["id"] == str( - task_assignee.task.id - ) + assert len(json["data"]) == expected + if expected: + assert json["data"][0]["id"] == str(task_assignee.id) + assert json["data"][0]["relationships"]["task"]["data"]["id"] == str( + task_assignee.task.id + ) diff --git a/timed/projects/views.py b/timed/projects/views.py index 196e5d59a..c3de6b3c6 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,12 +1,8 @@ """Viewsets for the projects app.""" -from datetime import date - from django.db.models import Q -from rest_framework.exceptions import PermissionDenied from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet -from timed.employment.models import Employment from timed.permissions import ( IsAuthenticated, IsInternal, @@ -34,23 +30,22 @@ def get_queryset(self): """ user = self.request.user queryset = models.Customer.objects.prefetch_related("projects") + current_employment = user.get_active_employment() - try: - current_employment = Employment.objects.get_at(user=user, date=date.today()) - if not current_employment.is_external: # pragma: no cover - return queryset - else: - return queryset.filter( - Q(assignees=user) - | Q(projects__assignees=user) - | Q(projects__tasks__assignees=user) - ) - except Employment.DoesNotExist: + if current_employment is None: if models.CustomerAssignee.objects.filter( user=user, is_customer=True ).exists(): - return queryset.filter(Q(assignees=user)) - raise PermissionDenied("User has no employment and isn't a customer!") + return queryset.filter(assignees=user) + elif not current_employment.is_external: # pragma: no cover + return queryset + elif current_employment.is_external: + return queryset.filter( + Q(assignees=user) + | Q(projects__assignees=user) + | Q(projects__tasks__assignees=user) + ) + return queryset.none() class BillingTypeViewSet(ReadOnlyModelViewSet): @@ -98,23 +93,22 @@ def get_queryset(self): .get_queryset() .select_related("customer", "billing_type", "cost_center") ) + current_employment = user.get_active_employment() - try: - current_employment = Employment.objects.get_at(user=user, date=date.today()) - if not current_employment.is_external: # pragma: no cover - return queryset - else: - return queryset.filter( - Q(assignees=user) - | Q(tasks__assignees=user) - | Q(customer__assignees=user) - ) - except Employment.DoesNotExist: + if current_employment is None: if models.CustomerAssignee.objects.filter( user=user, is_customer=True ).exists(): - return queryset.filter(Q(customer__assignees=user)) - raise PermissionDenied("User has no employment and isn't a customer!") + return queryset.filter(customer__assignees=user, customer_visible=True) + elif not current_employment.is_external: # pragma: no cover + return queryset + elif current_employment.is_external: + return queryset.filter( + Q(assignees=user) + | Q(tasks__assignees=user) + | Q(customer__assignees=user) + ) + return queryset.none() class TaskViewSet(ModelViewSet): @@ -147,23 +141,24 @@ def get_queryset(self): """Get only assigned tasks, if an employee is external.""" user = self.request.user queryset = super().get_queryset().select_related("project", "cost_center") + current_employment = user.get_active_employment() - try: - current_employment = Employment.objects.get_at(user=user, date=date.today()) - if not current_employment.is_external: # pragma: no cover - return queryset - else: - return queryset.filter( - Q(assignees=user) - | Q(project__assignees=user) - | Q(project__customer__assignees=user) - ) - except Employment.DoesNotExist: + if current_employment is None: if models.CustomerAssignee.objects.filter( user=user, is_customer=True ).exists(): - return queryset.filter(Q(project__customer__assignees=user)) - raise PermissionDenied("User has no employment and isn't a customer!") + return queryset.filter( + project__customer__assignees=user, project__customer_visible=True + ) + elif not current_employment.is_external: + return queryset + elif current_employment.is_external: + return queryset.filter( + Q(assignees=user) + | Q(project__assignees=user) + | Q(project__customer__assignees=user) + ) + return queryset.none() class TaskAsssigneeViewSet(ReadOnlyModelViewSet): @@ -171,7 +166,15 @@ class TaskAsssigneeViewSet(ReadOnlyModelViewSet): filterset_class = filters.TaskAssigneeFilterSet def get_queryset(self): - return models.TaskAssignee.objects.select_related("task", "user") + """Don't show task assignees to customers.""" + user = self.request.user + + queryset = models.TaskAssignee.objects.select_related("task", "user") + + current_employment = user.get_active_employment() + if current_employment is None or current_employment.is_external: + return queryset.none() + return queryset class ProjectAsssigneeViewSet(ReadOnlyModelViewSet): @@ -179,7 +182,15 @@ class ProjectAsssigneeViewSet(ReadOnlyModelViewSet): filterset_class = filters.ProjectAssigneeFilterSet def get_queryset(self): - return models.ProjectAssignee.objects.select_related("project", "user") + """Don't show project assignees to customers.""" + user = self.request.user + + queryset = models.ProjectAssignee.objects.select_related("project", "user") + + current_employment = user.get_active_employment() + if current_employment is None or current_employment.is_external: + return queryset.none() + return queryset class CustomerAsssigneeViewSet(ReadOnlyModelViewSet): @@ -187,4 +198,12 @@ class CustomerAsssigneeViewSet(ReadOnlyModelViewSet): filterset_class = filters.CustomerAssigneeFilterSet def get_queryset(self): - return models.CustomerAssignee.objects.select_related("customer", "user") + """Don't show customer assignees to customers.""" + user = self.request.user + + queryset = models.CustomerAssignee.objects.select_related("customer", "user") + + current_employment = user.get_active_employment() + if current_employment is None or current_employment.is_external: + return queryset.none() + return queryset diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 17bb30301..a271c7820 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -1,65 +1,108 @@ from datetime import timedelta +import pytest from django.urls import reverse +from rest_framework import status +from timed.conftest import setup_customer_and_employment_status +from timed.employment.factories import EmploymentFactory from timed.tracking.factories import ReportFactory -def test_customer_statistic_list(internal_employee_client, django_assert_num_queries): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected, status_code", + [ + (False, True, False, 1, status.HTTP_403_FORBIDDEN), + (False, True, True, 1, status.HTTP_403_FORBIDDEN), + (True, False, False, 4, status.HTTP_200_OK), + (True, True, False, 4, status.HTTP_200_OK), + (True, True, True, 4, status.HTTP_200_OK), + ], +) +def test_customer_statistic_list( + auth_client, + is_employed, + is_customer_assignee, + is_customer, + expected, + status_code, + django_assert_num_queries, +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) + report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("customer-statistic-list") - with django_assert_num_queries(4): - result = internal_employee_client.get( + with django_assert_num_queries(expected): + result = auth_client.get( url, data={"ordering": "duration", "include": "customer"} ) - assert result.status_code == 200 + assert result.status_code == status_code - json = result.json() - expected_data = [ - { - "type": "customer-statistics", - "id": str(report.task.project.customer.id), - "attributes": {"duration": "03:00:00"}, - "relationships": { - "customer": { - "data": { - "id": str(report.task.project.customer.id), - "type": "customers", + if status_code == status.HTTP_200_OK: + json = result.json() + expected_data = [ + { + "type": "customer-statistics", + "id": str(report.task.project.customer.id), + "attributes": {"duration": "03:00:00"}, + "relationships": { + "customer": { + "data": { + "id": str(report.task.project.customer.id), + "type": "customers", + } } - } + }, }, - }, - { - "type": "customer-statistics", - "id": str(report2.task.project.customer.id), - "attributes": {"duration": "04:00:00"}, - "relationships": { - "customer": { - "data": { - "id": str(report2.task.project.customer.id), - "type": "customers", + { + "type": "customer-statistics", + "id": str(report2.task.project.customer.id), + "attributes": {"duration": "04:00:00"}, + "relationships": { + "customer": { + "data": { + "id": str(report2.task.project.customer.id), + "type": "customers", + } } - } + }, }, - }, - ] - - assert json["data"] == expected_data - assert len(json["included"]) == 2 - assert json["meta"]["total-time"] == "07:00:00" + ] + assert json["data"] == expected_data + assert len(json["included"]) == 2 + assert json["meta"]["total-time"] == "07:00:00" -def test_customer_statistic_detail(internal_employee_client, django_assert_num_queries): +@pytest.mark.parametrize( + "is_employed, expected, status_code", + [ + (True, 5, status.HTTP_200_OK), + (False, 1, status.HTTP_403_FORBIDDEN), + ], +) +def test_customer_statistic_detail( + auth_client, is_employed, expected, status_code, django_assert_num_queries +): + if is_employed: + EmploymentFactory.create(user=auth_client.user) report = ReportFactory.create(duration=timedelta(hours=1)) url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) - with django_assert_num_queries(5): - result = internal_employee_client.get( + with django_assert_num_queries(expected): + result = auth_client.get( url, data={"ordering": "duration", "include": "customer"} ) - assert result.status_code == 200 - json = result.json() - assert json["data"]["attributes"]["duration"] == "01:00:00" + assert result.status_code == status_code + if status_code == status.HTTP_200_OK: + json = result.json() + assert json["data"]["attributes"]["duration"] == "01:00:00" diff --git a/timed/reports/tests/test_month_statistic.py b/timed/reports/tests/test_month_statistic.py index 8c17e6124..2ae274717 100644 --- a/timed/reports/tests/test_month_statistic.py +++ b/timed/reports/tests/test_month_statistic.py @@ -1,32 +1,55 @@ from datetime import date, timedelta +import pytest from django.urls import reverse +from rest_framework import status +from timed.conftest import setup_customer_and_employment_status from timed.tracking.factories import ReportFactory -def test_month_statistic_list(internal_employee_client): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected", + [ + (False, True, False, status.HTTP_403_FORBIDDEN), + (False, True, True, status.HTTP_403_FORBIDDEN), + (True, False, False, status.HTTP_200_OK), + (True, True, False, status.HTTP_200_OK), + (True, True, True, status.HTTP_200_OK), + ], +) +def test_month_statistic_list( + auth_client, is_employed, is_customer_assignee, is_customer, expected +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) + ReportFactory.create(duration=timedelta(hours=1), date=date(2016, 1, 1)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 4)) ReportFactory.create(duration=timedelta(hours=2), date=date(2015, 12, 31)) url = reverse("month-statistic-list") - result = internal_employee_client.get(url, data={"ordering": "year,month"}) - assert result.status_code == 200 - - json = result.json() - expected_json = [ - { - "type": "month-statistics", - "id": "201512", - "attributes": {"year": 2015, "month": 12, "duration": "03:00:00"}, - }, - { - "type": "month-statistics", - "id": "201601", - "attributes": {"year": 2016, "month": 1, "duration": "01:00:00"}, - }, - ] - - assert json["data"] == expected_json - assert json["meta"]["total-time"] == "04:00:00" + result = auth_client.get(url, data={"ordering": "year,month"}) + assert result.status_code == expected + if expected == status.HTTP_200_OK: + json = result.json() + expected_json = [ + { + "type": "month-statistics", + "id": "201512", + "attributes": {"year": 2015, "month": 12, "duration": "03:00:00"}, + }, + { + "type": "month-statistics", + "id": "201601", + "attributes": {"year": 2016, "month": 1, "duration": "01:00:00"}, + }, + ] + assert json["data"] == expected_json + assert json["meta"]["total-time"] == "04:00:00" diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index bbe2fd264..f709dee14 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -1,46 +1,75 @@ from datetime import timedelta +import pytest from django.urls import reverse +from rest_framework import status +from timed.conftest import setup_customer_and_employment_status from timed.tracking.factories import ReportFactory -def test_project_statistic_list(internal_employee_client, django_assert_num_queries): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected, status_code", + [ + (False, True, False, 1, status.HTTP_403_FORBIDDEN), + (False, True, True, 1, status.HTTP_403_FORBIDDEN), + (True, False, False, 4, status.HTTP_200_OK), + (True, True, False, 4, status.HTTP_200_OK), + (True, True, True, 4, status.HTTP_200_OK), + ], +) +def test_project_statistic_list( + auth_client, + is_employed, + is_customer_assignee, + is_customer, + expected, + status_code, + django_assert_num_queries, +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("project-statistic-list") - with django_assert_num_queries(4): - result = internal_employee_client.get( + with django_assert_num_queries(expected): + result = auth_client.get( url, data={"ordering": "duration", "include": "project,project.customer"} ) - assert result.status_code == 200 + assert result.status_code == status_code - json = result.json() - expected_json = [ - { - "type": "project-statistics", - "id": str(report.task.project.id), - "attributes": {"duration": "03:00:00"}, - "relationships": { - "project": { - "data": {"id": str(report.task.project.id), "type": "projects"} - } + if status_code == status.HTTP_200_OK: + json = result.json() + expected_json = [ + { + "type": "project-statistics", + "id": str(report.task.project.id), + "attributes": {"duration": "03:00:00"}, + "relationships": { + "project": { + "data": {"id": str(report.task.project.id), "type": "projects"} + } + }, }, - }, - { - "type": "project-statistics", - "id": str(report2.task.project.id), - "attributes": {"duration": "04:00:00"}, - "relationships": { - "project": { - "data": {"id": str(report2.task.project.id), "type": "projects"} - } + { + "type": "project-statistics", + "id": str(report2.task.project.id), + "attributes": {"duration": "04:00:00"}, + "relationships": { + "project": { + "data": {"id": str(report2.task.project.id), "type": "projects"} + } + }, }, - }, - ] - - assert json["data"] == expected_json - assert len(json["included"]) == 4 - assert json["meta"]["total-time"] == "07:00:00" + ] + assert json["data"] == expected_json + assert len(json["included"]) == 4 + assert json["meta"]["total-time"] == "07:00:00" diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index dd812d3ef..fed203dcd 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -1,12 +1,41 @@ from datetime import timedelta +import pytest from django.urls import reverse +from rest_framework import status +from timed.conftest import setup_customer_and_employment_status from timed.projects.factories import TaskFactory from timed.tracking.factories import ReportFactory -def test_task_statistic_list(internal_employee_client, django_assert_num_queries): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected, status_code", + [ + (False, True, False, 1, status.HTTP_403_FORBIDDEN), + (False, True, True, 1, status.HTTP_403_FORBIDDEN), + (True, False, False, 4, status.HTTP_200_OK), + (True, True, False, 4, status.HTTP_200_OK), + (True, True, True, 4, status.HTTP_200_OK), + ], +) +def test_task_statistic_list( + auth_client, + is_employed, + is_customer_assignee, + is_customer, + expected, + status_code, + django_assert_num_queries, +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) task_z = TaskFactory.create(name="Z") task_test = TaskFactory.create(name="Test") ReportFactory.create(duration=timedelta(hours=1), task=task_test) @@ -14,36 +43,36 @@ def test_task_statistic_list(internal_employee_client, django_assert_num_queries ReportFactory.create(duration=timedelta(hours=2), task=task_z) url = reverse("task-statistic-list") - with django_assert_num_queries(4): - result = internal_employee_client.get( + with django_assert_num_queries(expected): + result = auth_client.get( url, data={ "ordering": "task__name", "include": "task,task.project,task.project.customer", }, ) - assert result.status_code == 200 + assert result.status_code == status_code - json = result.json() - expected_json = [ - { - "type": "task-statistics", - "id": str(task_test.id), - "attributes": {"duration": "03:00:00"}, - "relationships": { - "task": {"data": {"id": str(task_test.id), "type": "tasks"}} + if status_code == status.HTTP_200_OK: + json = result.json() + expected_json = [ + { + "type": "task-statistics", + "id": str(task_test.id), + "attributes": {"duration": "03:00:00"}, + "relationships": { + "task": {"data": {"id": str(task_test.id), "type": "tasks"}} + }, }, - }, - { - "type": "task-statistics", - "id": str(task_z.id), - "attributes": {"duration": "02:00:00"}, - "relationships": { - "task": {"data": {"id": str(task_z.id), "type": "tasks"}} + { + "type": "task-statistics", + "id": str(task_z.id), + "attributes": {"duration": "02:00:00"}, + "relationships": { + "task": {"data": {"id": str(task_z.id), "type": "tasks"}} + }, }, - }, - ] - - assert json["data"] == expected_json - assert len(json["included"]) == 6 - assert json["meta"]["total-time"] == "05:00:00" + ] + assert json["data"] == expected_json + assert len(json["included"]) == 6 + assert json["meta"]["total-time"] == "05:00:00" diff --git a/timed/reports/tests/test_user_statistic.py b/timed/reports/tests/test_user_statistic.py index 3e7d93437..50ebcc857 100644 --- a/timed/reports/tests/test_user_statistic.py +++ b/timed/reports/tests/test_user_statistic.py @@ -1,40 +1,62 @@ from datetime import timedelta +import pytest from django.urls import reverse +from rest_framework import status +from timed.conftest import setup_customer_and_employment_status from timed.tracking.factories import ReportFactory -def test_user_statistic_list(internal_employee_client): - user = internal_employee_client.user +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, status_code", + [ + (False, True, False, status.HTTP_403_FORBIDDEN), + (False, True, True, status.HTTP_403_FORBIDDEN), + (True, False, False, status.HTTP_200_OK), + (True, True, False, status.HTTP_200_OK), + (True, True, True, status.HTTP_200_OK), + ], +) +def test_user_statistic_list( + auth_client, is_employed, is_customer_assignee, is_customer, status_code +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) ReportFactory.create(duration=timedelta(hours=1), user=user) ReportFactory.create(duration=timedelta(hours=2), user=user) report = ReportFactory.create(duration=timedelta(hours=2)) url = reverse("user-statistic-list") - result = internal_employee_client.get( - url, data={"ordering": "duration", "include": "user"} - ) - assert result.status_code == 200 + result = auth_client.get(url, data={"ordering": "duration", "include": "user"}) + assert result.status_code == status_code - json = result.json() - expected_json = [ - { - "type": "user-statistics", - "id": str(report.user.id), - "attributes": {"duration": "02:00:00"}, - "relationships": { - "user": {"data": {"id": str(report.user.id), "type": "users"}} + if status_code == status.HTTP_200_OK: + json = result.json() + expected_json = [ + { + "type": "user-statistics", + "id": str(report.user.id), + "attributes": {"duration": "02:00:00"}, + "relationships": { + "user": {"data": {"id": str(report.user.id), "type": "users"}} + }, }, - }, - { - "type": "user-statistics", - "id": str(user.id), - "attributes": {"duration": "03:00:00"}, - "relationships": {"user": {"data": {"id": str(user.id), "type": "users"}}}, - }, - ] - - assert json["data"] == expected_json - assert len(json["included"]) == 2 - assert json["meta"]["total-time"] == "05:00:00" + { + "type": "user-statistics", + "id": str(user.id), + "attributes": {"duration": "03:00:00"}, + "relationships": { + "user": {"data": {"id": str(user.id), "type": "users"}} + }, + }, + ] + assert json["data"] == expected_json + assert len(json["included"]) == 2 + assert json["meta"]["total-time"] == "05:00:00" diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index e4586d3cc..db88f671e 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -7,14 +7,41 @@ from django.urls import reverse from rest_framework import status +from timed.conftest import setup_customer_and_employment_status +from timed.employment.factories import EmploymentFactory from timed.projects.factories import CustomerFactory, ProjectFactory, TaskFactory from timed.reports.views import WorkReportViewSet from timed.tracking.factories import ReportFactory @pytest.mark.freeze_time("2017-09-01") -def test_work_report_single_project(auth_client, django_assert_num_queries): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected, status_code", + [ + (False, True, False, 1, status.HTTP_400_BAD_REQUEST), + (False, True, True, 1, status.HTTP_400_BAD_REQUEST), + (True, False, False, 4, status.HTTP_200_OK), + (True, True, False, 4, status.HTTP_200_OK), + (True, True, True, 4, status.HTTP_200_OK), + ], +) +def test_work_report_single_project( + auth_client, + is_employed, + is_customer_assignee, + is_customer, + expected, + status_code, + django_assert_num_queries, +): user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) # spaces should be replaced with underscore customer = CustomerFactory.create(name="Customer Name") # slashes should be dropped from file name @@ -38,7 +65,7 @@ def test_work_report_single_project(auth_client, django_assert_num_queries): ) url = reverse("work-report-list") - with django_assert_num_queries(3): + with django_assert_num_queries(expected): res = auth_client.get( url, data={ @@ -48,23 +75,36 @@ def test_work_report_single_project(auth_client, django_assert_num_queries): "verified": 1, }, ) - assert res.status_code == status.HTTP_200_OK - assert "1708-20170901-Customer_Name-Project.ods" in (res["Content-Disposition"]) + assert res.status_code == status_code + + if status_code == status.HTTP_200_OK: + assert "1708-20170901-Customer_Name-Project.ods" in (res["Content-Disposition"]) - content = io.BytesIO(res.content) - doc = ezodf.opendoc(content) - table = doc.sheets[0] - assert table["C5"].value == "2017-08-01" - assert table["C6"].value == "2017-08-31" - assert table["C9"].value == "Test User" - assert table["C10"].value == "Test User" + content = io.BytesIO(res.content) + doc = ezodf.opendoc(content) + table = doc.sheets[0] + assert table["C5"].value == "2017-08-01" + assert table["C6"].value == "2017-08-31" + assert table["C9"].value == "Test User" + assert table["C10"].value == "Test User" @pytest.mark.freeze_time("2017-09-01") -def test_work_report_multiple_projects(auth_client, django_assert_num_queries): +@pytest.mark.parametrize( + "is_employed, status_code, expected", + [ + (True, status.HTTP_200_OK, 4), + (False, status.HTTP_400_BAD_REQUEST, 1), + ], +) +def test_work_report_multiple_projects( + auth_client, is_employed, status_code, expected, django_assert_num_queries +): NUM_PROJECTS = 2 user = auth_client.user + if is_employed: + EmploymentFactory.create(user=user) customer = CustomerFactory.create(name="Customer") report_date = date(2017, 8, 17) for i in range(NUM_PROJECTS): @@ -73,21 +113,22 @@ def test_work_report_multiple_projects(auth_client, django_assert_num_queries): ReportFactory.create_batch(10, user=user, task=task, date=report_date) url = reverse("work-report-list") - with django_assert_num_queries(3): + with django_assert_num_queries(expected): res = auth_client.get(url, data={"user": auth_client.user.id, "verified": 0}) - assert res.status_code == status.HTTP_200_OK - assert "20170901-WorkReports.zip" in (res["Content-Disposition"]) - - content = io.BytesIO(res.content) - with ZipFile(content, "r") as zipfile: - for i in range(NUM_PROJECTS): - ods_content = zipfile.read( - "1708-20170901-Customer-Project{0}.ods".format(i) - ) - doc = ezodf.opendoc(io.BytesIO(ods_content)) - table = doc.sheets[0] - assert table["C5"].value == "2017-08-17" - assert table["C6"].value == "2017-08-17" + assert res.status_code == status_code + if status_code == status.HTTP_200_OK: + assert "20170901-WorkReports.zip" in (res["Content-Disposition"]) + + content = io.BytesIO(res.content) + with ZipFile(content, "r") as zipfile: + for i in range(NUM_PROJECTS): + ods_content = zipfile.read( + "1708-20170901-Customer-Project{0}.ods".format(i) + ) + doc = ezodf.opendoc(io.BytesIO(ods_content)) + table = doc.sheets[0] + assert table["C5"].value == "2017-08-17" + assert table["C6"].value == "2017-08-17" def test_work_report_empty(auth_client): @@ -128,9 +169,9 @@ def test_generate_work_report_name(db, customer_name, project_name, expected): ], ) def test_work_report_count( - auth_client, settings, settings_count, given_count, expected_status + internal_employee_client, settings, settings_count, given_count, expected_status ): - user = auth_client.user + user = internal_employee_client.user customer = CustomerFactory.create(name="Customer") report_date = date(2017, 8, 17) @@ -141,6 +182,8 @@ def test_work_report_count( ReportFactory.create_batch(given_count, user=user, task=task, date=report_date) url = reverse("work-report-list") - res = auth_client.get(url, data={"user": auth_client.user.id, "verified": 0}) + res = internal_employee_client.get( + url, data={"user": internal_employee_client.user.id, "verified": 0} + ) assert res.status_code == expected_status diff --git a/timed/reports/tests/test_year_statistic.py b/timed/reports/tests/test_year_statistic.py index 9bcd8e6e6..132b33863 100644 --- a/timed/reports/tests/test_year_statistic.py +++ b/timed/reports/tests/test_year_statistic.py @@ -1,43 +1,78 @@ from datetime import date, timedelta +import pytest from django.urls import reverse +from rest_framework import status +from timed.conftest import setup_customer_and_employment_status +from timed.employment.factories import EmploymentFactory from timed.tracking.factories import ReportFactory -def test_year_statistic_list(internal_employee_client): +@pytest.mark.parametrize( + "is_employed, is_customer_assignee, is_customer, expected", + [ + (False, True, False, status.HTTP_403_FORBIDDEN), + (False, True, True, status.HTTP_403_FORBIDDEN), + (True, False, False, status.HTTP_200_OK), + (True, True, False, status.HTTP_200_OK), + (True, True, True, status.HTTP_200_OK), + ], +) +def test_year_statistic_list( + auth_client, is_employed, is_customer_assignee, is_customer, expected +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=is_customer_assignee, + is_customer=is_customer, + is_employed=is_employed, + is_external=False, + ) + ReportFactory.create(duration=timedelta(hours=1), date=date(2017, 1, 1)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) url = reverse("year-statistic-list") - result = internal_employee_client.get(url, data={"ordering": "year"}) - assert result.status_code == 200 - - json = result.json() - expected_json = [ - { - "type": "year-statistics", - "id": "2015", - "attributes": {"year": 2015, "duration": "02:00:00"}, - }, - { - "type": "year-statistics", - "id": "2017", - "attributes": {"year": 2017, "duration": "01:00:00"}, - }, - ] - - assert json["data"] == expected_json - assert json["meta"]["total-time"] == "03:00:00" - - -def test_year_statistic_detail(internal_employee_client): + result = auth_client.get(url, data={"ordering": "year"}) + assert result.status_code == expected + + if expected == status.HTTP_200_OK: + json = result.json() + expected_json = [ + { + "type": "year-statistics", + "id": "2015", + "attributes": {"year": 2015, "duration": "02:00:00"}, + }, + { + "type": "year-statistics", + "id": "2017", + "attributes": {"year": 2017, "duration": "01:00:00"}, + }, + ] + assert json["data"] == expected_json + assert json["meta"]["total-time"] == "03:00:00" + + +@pytest.mark.parametrize( + "is_employed, expected", + [ + (True, status.HTTP_200_OK), + (False, status.HTTP_403_FORBIDDEN), + ], +) +def test_year_statistic_detail(auth_client, is_employed, expected): + if is_employed: + EmploymentFactory.create(user=auth_client.user) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 2, 28)) ReportFactory.create(duration=timedelta(hours=1), date=date(2015, 12, 31)) url = reverse("year-statistic-detail", args=[2015]) - result = internal_employee_client.get(url, data={"ordering": "year"}) - assert result.status_code == 200 - json = result.json() - assert json["data"]["attributes"]["duration"] == "02:00:00" + result = auth_client.get(url, data={"ordering": "year"}) + assert result.status_code == expected + if expected == status.HTTP_200_OK: + json = result.json() + assert json["data"]["attributes"]["duration"] == "02:00:00" diff --git a/timed/reports/views.py b/timed/reports/views.py index 8050c982f..e43a662f4 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -39,6 +39,7 @@ def get_queryset(self): queryset = queryset.annotate(year=ExtractYear("date")).values("year") queryset = queryset.annotate(duration=Sum("duration")) queryset = queryset.annotate(pk=F("year")) + return queryset @@ -63,6 +64,7 @@ def get_queryset(self): queryset = queryset.values("year", "month") queryset = queryset.annotate(duration=Sum("duration")) queryset = queryset.annotate(pk=F("year") * 100 + F("month")) + return queryset @@ -81,7 +83,6 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values("task__project__customer") queryset = queryset.annotate(duration=Sum("duration")) queryset = queryset.annotate(pk=F("task__project__customer")) @@ -104,7 +105,6 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values("task__project") queryset = queryset.annotate(duration=Sum("duration")) queryset = queryset.annotate(pk=F("task__project")) @@ -127,7 +127,6 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values("task") queryset = queryset.annotate(duration=Sum("duration")) queryset = queryset.annotate(pk=F("task")) @@ -150,7 +149,6 @@ class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): def get_queryset(self): queryset = Report.objects.all() - queryset = queryset.values("user") queryset = queryset.annotate(duration=Sum("duration")) queryset = queryset.annotate(pk=F("user")) @@ -171,7 +169,10 @@ class WorkReportViewSet(GenericViewSet): ordering_fields = ReportViewSet.ordering_fields def get_queryset(self): - return Report.objects.select_related( + """Don't show any reports to customers.""" + user = self.request.user + + queryset = Report.objects.select_related( "user", "task", "task__project", "task__project__customer" ).prefetch_related( # need to prefetch verified_by as select_related joins nullable @@ -182,6 +183,10 @@ def get_queryset(self): "verified_by" ) + if user.get_active_employment(): + return queryset + return queryset.none() + def _parse_query_params(self, queryset, request): """Parse query params by using filterset_class.""" fltr = self.filterset_class( diff --git a/timed/subscription/views.py b/timed/subscription/views.py index d7778ed79..d500b2186 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,10 +1,7 @@ -from datetime import date - from django.db.models import Q from rest_framework import decorators, exceptions, response, status, viewsets from rest_framework_json_api.serializers import ValidationError -from timed.employment.models import Employment from timed.permissions import ( IsAccountant, IsAuthenticated, @@ -34,13 +31,9 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): user = self.request.user queryset = Project.objects.filter(archived=False, customer_visible=True) - try: - # check if user is an internal employee - current_employment = Employment.objects.get_at(user=user, date=date.today()) - if not current_employment.is_external: # pragma: no cover - return queryset - except Employment.DoesNotExist: - # if user has no employment, check if he's a customer + current_employment = user.get_active_employment() + + if current_employment is None or current_employment.is_external: if CustomerAssignee.objects.filter(user=user, is_customer=True).exists(): return queryset.filter( Q( @@ -48,6 +41,8 @@ def get_queryset(self): customer__customer_assignees__is_customer=True, ) ) + elif not current_employment.is_external: + return queryset return queryset.none() From 1ce24bd04f4b217e560707bd699bbeb6fe14fe09 Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Mon, 3 Jan 2022 16:10:32 +0100 Subject: [PATCH 887/980] fix(auth): username should be case insensitive --- timed/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/authentication.py b/timed/authentication.py index 5d158e439..9efc20724 100644 --- a/timed/authentication.py +++ b/timed/authentication.py @@ -81,7 +81,7 @@ def update_user_from_claims(self, user, claims): def filter_users_by_claims(self, claims): username = self.get_username(claims) - return self.UserModel.objects.filter(username=username) + return self.UserModel.objects.filter(username__iexact=username) def cached_request(self, method, token, cache_prefix): token_hash = hashlib.sha256(force_bytes(token)).hexdigest() From 1acd3742af972e17d8600b560f16f7afe9a70d1d Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 4 Jan 2022 09:20:00 +0100 Subject: [PATCH 888/980] fix(reports): center total hours column in workreport --- timed/reports/templates/workreport.ots | Bin 159982 -> 160015 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index 94eff7db531d8123333893efc82db4984923a321..a81a5af7b9b9197b07fdfc71f9142635abd7f282 100644 GIT binary patch delta 11139 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 From 91751e9497ac67ecb3072e33a6c990169d8488ee Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 11 Jan 2022 10:33:47 +0100 Subject: [PATCH 889/980] fix(reports): add reviewer hierarchy in notify_reviewers_unverified --- .../commands/notify_reviewers_unverified.py | 58 ++++++++++++++++--- .../tests/test_notify_reviewers_unverified.py | 38 +++++++++++- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/reports/management/commands/notify_reviewers_unverified.py index abb1fcc0b..37d584c34 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/reports/management/commands/notify_reviewers_unverified.py @@ -8,6 +8,7 @@ from django.db.models import Q from django.template.loader import get_template +from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee from timed.tracking.models import Report template = get_template("mail/notify_reviewers_unverified.txt", using="text") @@ -89,7 +90,12 @@ def _get_unverified_reports(self, start, end): return Report.objects.filter(date__range=[start, end], verified_by__isnull=True) def _notify_reviewers(self, start, end, reports, optional_message, cc): - """Notify reviewers on their unverified reports.""" + """Notify reviewers on their unverified reports. + + Only the reviewers lowest in the hierarchy should be notified. + If a project has a project assignee and a task assignee with reviewer role, + then only the task assignee should be notified about unverified reports. + """ User = get_user_model() reviewers = User.objects.all_reviewers().filter(email__isnull=False) subject = "[Timed] Verification of reports" @@ -98,19 +104,53 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): messages = [] for reviewer in reviewers: - if reports.filter( + # unverified reports in which user is customer assignee and responsible reviewer + reports_customer_assignee_is_reviewer = reports.filter( Q( - task__task_assignees__user=reviewer, - task__task_assignees__is_reviewer=True, + task__project__customer_id__in=CustomerAssignee.objects.filter( + is_reviewer=True, user_id=reviewer + ).values("customer_id") ) - | Q( - task__project__project_assignees__user=reviewer, - task__project__project_assignees__is_reviewer=True, + ).exclude( + Q( + task__project_id__in=ProjectAssignee.objects.filter( + is_reviewer=True + ).values("project_id") ) | Q( - task__project__customer__customer_assignees__user=reviewer, - task__project__customer__customer_assignees__is_reviewer=True, + task_id__in=TaskAssignee.objects.filter(is_reviewer=True).values( + "task_id" + ) + ) + ) + + # unverified reports in which user is project assignee and responsible reviewer + reports_project_assignee_is_reviewer = reports.filter( + Q( + task__project_id__in=ProjectAssignee.objects.filter( + is_reviewer=True, user_id=reviewer + ).values("project_id") + ) + ).exclude( + Q( + task_id__in=TaskAssignee.objects.filter(is_reviewer=True).values( + "task_id" + ) + ) + ) + + # unverified reports in which user task assignee and responsible reviewer + reports_task_assignee_is_reviewer = reports.filter( + Q( + task_id__in=TaskAssignee.objects.filter( + is_reviewer=True, user_id=reviewer + ).values("task_id") ) + ) + if ( + reports_customer_assignee_is_reviewer + | reports_project_assignee_is_reviewer + | reports_task_assignee_is_reviewer ).exists(): body = template.render( { diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/reports/tests/test_notify_reviewers_unverified.py index 807f46fa2..4cd2f63c5 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/reports/tests/test_notify_reviewers_unverified.py @@ -4,7 +4,12 @@ from django.core.management import call_command from timed.employment.factories import UserFactory -from timed.projects.factories import ProjectAssigneeFactory, ProjectFactory, TaskFactory +from timed.projects.factories import ( + ProjectAssigneeFactory, + ProjectFactory, + TaskAssigneeFactory, + TaskFactory, +) from timed.tracking.factories import ReportFactory @@ -82,3 +87,34 @@ def test_notify_reviewers(db, mailoutbox): "toDate=2017-07-31&reviewer=%d&editable=1" ) % reviewer_work.id assert url in mail.body + + +@pytest.mark.freeze_time("2017-8-4") +def test_notify_reviewers_reviewer_hierarchy(db, mailoutbox): + """Test notification with reviewer hierarchy. + + Test if only the lowest in reviewer hierarchy gets notified. + """ + # user that shouldn't be notified + project_reviewer = UserFactory.create() + # user that should be notified + task_reviewer = UserFactory.create() + project = ProjectFactory.create() + task = TaskFactory.create(project=project) + ProjectAssigneeFactory.create( + user=project_reviewer, project=project, is_reviewer=True + ) + TaskAssigneeFactory.create(user=task_reviewer, task=task, is_reviewer=True) + + ReportFactory.create(date=date(2017, 7, 1), task=task, verified_by=None) + + call_command("notify_reviewers_unverified") + + assert len(mailoutbox) == 1 + mail = mailoutbox[0] + assert mail.to == [task_reviewer.email] + url = ( + "http://localhost:4200/analysis?fromDate=2017-07-01&" + "toDate=2017-07-31&reviewer=%d&editable=1" + ) % task_reviewer.id + assert url in mail.body From c68107a4a58f54fbaa2c1de2f158437ad78609f3 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 25 Jan 2022 14:35:35 +0100 Subject: [PATCH 890/980] feat: add tls option for emails to env var --- timed/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/timed/settings.py b/timed/settings.py index ef1cf637d..c00a29fa8 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -281,6 +281,8 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): EMAIL_CONFIG = env.email_url("EMAIL_URL", default="smtp://localhost:25") vars().update(EMAIL_CONFIG) +EMAIL_USE_TLS = env.bool("DJANGO_EMAIL_USE_TLS", default=True) + DEFAULT_FROM_EMAIL = env.str( "DJANGO_DEFAULT_FROM_EMAIL", default("webmaster@localhost") ) From 11640f88d797480a5f110fc7fc9b27d262f22bfa Mon Sep 17 00:00:00 2001 From: Akanksh Saxena Date: Tue, 25 Jan 2022 14:48:53 +0100 Subject: [PATCH 891/980] fix(SubscriptionProject): incude cost center --- timed/subscription/serializers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index ebd77dee7..30a443672 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -42,6 +42,7 @@ def get_spent_time(self, obj): included_serializers = { "billing_type": "timed.projects.serializers.BillingTypeSerializer", + "cost_center": "timed.projects.serializers.CostCenterSerializer", "customer": "timed.projects.serializers.CustomerSerializer", "orders": "timed.subscription.serializers.OrderSerializer", } @@ -52,6 +53,7 @@ class Meta: fields = ( "name", "billing_type", + "cost_center", "purchased_time", "spent_time", "customer", From 8a705dbca7a66abd443f0a99341004c3515f3dbd Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 27 Jan 2022 09:51:39 +0100 Subject: [PATCH 892/980] fix(projects): change permissions and visibility for billing types --- timed/projects/tests/test_billing_type.py | 46 ++++++++++++++++------- timed/projects/views.py | 36 +++++++++++++++++- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/timed/projects/tests/test_billing_type.py b/timed/projects/tests/test_billing_type.py index 6deb8f52b..f4e9fcb21 100644 --- a/timed/projects/tests/test_billing_type.py +++ b/timed/projects/tests/test_billing_type.py @@ -3,21 +3,34 @@ from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN from timed.conftest import setup_customer_and_employment_status -from timed.projects.factories import BillingTypeFactory +from timed.projects import factories, models @pytest.mark.parametrize( - "is_employed, is_customer_assignee, is_customer, status_code", + "is_employed, is_external, is_customer_assignee, is_customer, customer_visible, expected, status_code", [ - (False, True, False, HTTP_403_FORBIDDEN), - (False, True, True, HTTP_403_FORBIDDEN), - (True, False, False, HTTP_200_OK), - (True, True, False, HTTP_200_OK), - (True, True, True, HTTP_200_OK), + (False, False, True, False, False, 0, HTTP_403_FORBIDDEN), + (False, False, True, True, False, 0, HTTP_200_OK), + (False, False, True, True, True, 1, HTTP_200_OK), + (True, False, False, False, False, 1, HTTP_200_OK), + (True, True, False, False, False, 1, HTTP_403_FORBIDDEN), + (True, False, True, False, False, 1, HTTP_200_OK), + (True, True, True, False, False, 1, HTTP_403_FORBIDDEN), + (True, False, True, True, False, 1, HTTP_200_OK), + (True, True, True, True, False, 0, HTTP_200_OK), + (True, False, True, True, True, 1, HTTP_200_OK), + (True, True, True, True, True, 1, HTTP_200_OK), ], ) def test_billing_type_list( - auth_client, is_employed, is_customer_assignee, is_customer, status_code + auth_client, + is_employed, + is_external, + is_customer_assignee, + is_customer, + customer_visible, + expected, + status_code, ): user = auth_client.user setup_customer_and_employment_status( @@ -25,14 +38,21 @@ def test_billing_type_list( is_assignee=is_customer_assignee, is_customer=is_customer, is_employed=is_employed, - is_external=False, + is_external=is_external, ) - billing_type = BillingTypeFactory.create() + if is_customer_assignee: + customer = models.Customer.objects.get(customer_assignees__user=user) + else: + customer = factories.CustomerFactory.create() + project = factories.ProjectFactory.create( + customer_visible=customer_visible, customer=customer + ) + url = reverse("billing-type-list") res = auth_client.get(url) assert res.status_code == status_code - if res.status_code == HTTP_200_OK: + if res.status_code == HTTP_200_OK and expected: json = res.json() - assert len(json["data"]) == 1 - assert json["data"][0]["id"] == str(billing_type.id) + assert len(json["data"]) == expected + assert json["data"][0]["id"] == str(project.billing_type.id) diff --git a/timed/projects/views.py b/timed/projects/views.py index c3de6b3c6..d05e77ca8 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -5,6 +5,7 @@ from timed.permissions import ( IsAuthenticated, + IsCustomer, IsInternal, IsManager, IsReadOnly, @@ -55,11 +56,42 @@ class BillingTypeViewSet(ReadOnlyModelViewSet): # superuser may edit all billing types IsSuperUser # internal employees may read all billing types - | IsAuthenticated & IsInternal & IsReadOnly + | IsAuthenticated & (IsInternal | IsCustomer) & IsReadOnly ] def get_queryset(self): - return models.BillingType.objects.all() + """Get billing types depending on the user's role. + + Internal employees should see all billing types. + Customers should only see billing types that are used in customer visible projects. + """ + user = self.request.user + queryset = models.BillingType.objects.all() + + current_employment = user.get_active_employment() + + if current_employment: + if ( + current_employment.is_external + and models.CustomerAssignee.objects.filter( + user=user, is_customer=True + ).exists() + ): + return queryset.filter( + projects__customer_visible=True, + projects__customer__customer_assignees__user=user, + projects__customer__customer_assignees__is_customer=True, + ) + return queryset + else: + if models.CustomerAssignee.objects.filter( + user=user, is_customer=True + ).exists(): + return queryset.filter( + projects__customer_visible=True, + projects__customer__customer_assignees__user=user, + projects__customer__customer_assignees__is_customer=True, + ) class CostCenterViewSet(ReadOnlyModelViewSet): From 0deaafa71d8520c7bf17fc91aa938f0106f96150 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 8 Feb 2022 16:01:05 +0100 Subject: [PATCH 893/980] fix(subscription): fix parser and notifications for orders notify only on orders created by customers fix parser to correctly display purchased timed --- timed/subscription/notify_admin.py | 12 ++---- timed/subscription/tests/test_order.py | 56 ++++++++++++++++++++++++-- timed/subscription/views.py | 9 ++++- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/timed/subscription/notify_admin.py b/timed/subscription/notify_admin.py index 103f04a00..f93dd21b5 100644 --- a/timed/subscription/notify_admin.py +++ b/timed/subscription/notify_admin.py @@ -1,6 +1,5 @@ import datetime -from dateutil import parser from django.conf import settings from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import get_template, render_to_string @@ -14,13 +13,10 @@ def prepare_and_send_email(project, order_duration): customer = project.customer - duration = parser.parse(order_duration) - hours_added = datetime.timedelta( - days=duration.day, - hours=duration.hour, - minutes=duration.minute, - seconds=duration.second, - ) + duration = order_duration.split(":") + hours = int(duration[0]) + minutes = int(duration[1]) + hours_added = datetime.timedelta(hours=hours, minutes=minutes) hours_total = hours_added if project.estimated_time is not None: diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index 61434535d..6fb38ad81 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -138,10 +138,10 @@ def test_order_confirm( status.HTTP_400_BAD_REQUEST, ), (True, False, False, False, 1, timedelta(hours=1), status.HTTP_201_CREATED), - (False, True, False, True, 1, timedelta(hours=10), status.HTTP_201_CREATED), - (False, True, False, False, 1, timedelta(hours=24), status.HTTP_201_CREATED), - (False, False, True, True, 1, timedelta(hours=50), status.HTTP_201_CREATED), - (False, False, True, False, 1, timedelta(hours=100), status.HTTP_201_CREATED), + (False, True, False, True, 0, timedelta(hours=10), status.HTTP_201_CREATED), + (False, True, False, False, 0, timedelta(hours=24), status.HTTP_201_CREATED), + (False, False, True, True, 0, timedelta(hours=50), status.HTTP_201_CREATED), + (False, False, True, False, 0, timedelta(hours=100), status.HTTP_201_CREATED), (False, False, False, True, 0, timedelta(hours=200), status.HTTP_403_FORBIDDEN), (False, False, False, False, 0, None, status.HTTP_403_FORBIDDEN), ], @@ -206,6 +206,54 @@ def test_order_create( assert url in mail.alternatives[0][0] +@pytest.mark.parametrize( + "duration, expected, status_code", + [ + ("00:30:00", "0:30:00", status.HTTP_201_CREATED), + ("30:00:00", "1 day, 6:00:00", status.HTTP_201_CREATED), + ("30:30:00", "1 day, 6:30:00", status.HTTP_201_CREATED), + ("-00:30:00", "-0:30:00", status.HTTP_400_BAD_REQUEST), + ("-30:00:00", "-1 day, 6:00:00", status.HTTP_400_BAD_REQUEST), + ("-30:30:00", "-1 day, 6:30:00", status.HTTP_400_BAD_REQUEST), + ], +) +def test_order_create_duration( + auth_client, mailoutbox, duration, expected, status_code +): + user = auth_client.user + project = ProjectFactory.create(estimated_time=timedelta(hours=1)) + CustomerAssigneeFactory.create( + user=user, is_customer=True, customer=project.customer + ) + + data = { + "data": { + "type": "subscription-orders", + "id": None, + "attributes": { + "acknowledged": False, + "duration": duration, + }, + "relationships": { + "project": { + "data": {"type": "subscription-projects", "id": project.id} + }, + }, + } + } + + url = reverse("subscription-order-list") + + response = auth_client.post(url, data) + assert response.status_code == status_code + + if status_code == status.HTTP_201_CREATED: + assert len(mailoutbox) == 1 + + mail = mailoutbox[0] + assert expected in mail.body + + @pytest.mark.parametrize( "is_customer, is_accountant, is_superuser, acknowledged, expected", [ diff --git a/timed/subscription/views.py b/timed/subscription/views.py index d500b2186..941afaefd 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -79,7 +79,14 @@ def create(self, request, *args, **kwargs): project = Project.objects.get(id=request.data.get("project")["id"]) order_duration = request.data.get("duration") - notify_admin.prepare_and_send_email(project, order_duration) + # only send notification emails if order was created by a customer + # don't allow customers to create orders with negative duration + if not (request.user.is_accountant or request.user.is_superuser): + if "-" in request.data.get("duration"): + raise ValidationError( + "Customer can not create orders with negative duration!" + ) + notify_admin.prepare_and_send_email(project, order_duration) return super().create(request, *args, **kwargs) @decorators.action( From e73e7161d51b93b14faa0a5f5babf740166aff06 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 22 Feb 2022 10:06:15 +0100 Subject: [PATCH 894/980] fix(tracking): allow updating billed reports --- timed/tracking/serializers.py | 13 ++++++++++--- timed/tracking/tests/test_report.py | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index baa94ff59..e2a43d060 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -117,6 +117,16 @@ def validate_duration(self, value): """Only owner is allowed to change duration.""" return self._validate_owner_only(value, "duration") + def validate_billed(self, value): + """Only accountants may bill reports.""" + if self.instance is not None: + if not self.context["request"].user.is_accountant and ( + self.instance.billed != value + ): + raise ValidationError(_("Only accountants may bill reports.")) + + return value + def validate(self, data): """ Validate that verified by is only set by reviewer or superuser. @@ -166,9 +176,6 @@ def validate(self, data): _("Report can't both be set as `review` and `verified`.") ) - if not user.is_accountant and billed: - raise ValidationError(_("Only accountants may bill reports.")) - # update billed flag on created reports if not self.instance or billed is None: data["billed"] = task.project.billed diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 592069957..9d0d6b106 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1529,12 +1529,24 @@ def test_report_update_billed_user( assert response.status_code == status.HTTP_403_FORBIDDEN +@pytest.mark.parametrize( + "is_accountant, expected", + [ + (True, status.HTTP_200_OK), + (False, status.HTTP_400_BAD_REQUEST), + ], +) def test_report_set_billed_by_user( internal_employee_client, report_factory, + is_accountant, + expected, ): """Test that normal user may not bill report.""" user = internal_employee_client.user + if is_accountant: + user.is_accountant = True + user.save() report = report_factory.create(user=user) data = { "data": { @@ -1546,12 +1558,12 @@ def test_report_set_billed_by_user( url = reverse("report-detail", args=[report.id]) response = internal_employee_client.patch(url, data) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == expected def test_report_update_billed(internal_employee_client, report_factory, task): user = internal_employee_client.user - report = report_factory.create(user=user) + report = report_factory.create(user=user, billed=True) report.task.project.billed = True report.task.project.save() From ca8b76dd2d1f2ce365595101bb4a6d53aa85994d Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 14 Apr 2022 10:45:26 +0200 Subject: [PATCH 895/980] chore(deps): update django to 3.2.13 update black and django-money --- requirements-dev.txt | 2 +- requirements.txt | 4 +- setup.cfg | 2 +- timed/employment/__init__.py | 3 - timed/reports/tests/test_work_report.py | 2 +- timed/settings.py | 7 +- .../0005_alter_package_price_currency.py | 334 ++++++++++++++++++ 7 files changed, 343 insertions(+), 11 deletions(-) create mode 100644 timed/subscription/migrations/0005_alter_package_price_currency.py diff --git a/requirements-dev.txt b/requirements-dev.txt index 03dcb6c36..6e91530dc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -black==21.6b0 +black==22.3.0 coverage==5.5 factory-boy==3.2.1 flake8==3.9.2 diff --git a/requirements.txt b/requirements.txt index bf7980259..b3c6860ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ python-dateutil==2.8.1 -django==3.1.14 +django==3.2.13 # might remove this once we find out how the jsonapi extras_require work django-cors-headers==3.10.1 django-filter==2.4.0 @@ -18,7 +18,7 @@ pyexcel-ods3==0.6.0 pyexcel-xlsx==0.6.0 pyexcel-ezodf==0.3.4 django-environ==0.4.5 -django-money==1.3.1 +django-money==2.1.1 python-redmine==2.3.0 sentry-sdk==1.5.1 gunicorn==20.1.0 diff --git a/setup.cfg b/setup.cfg index ba0a0a0fb..907f0049f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,7 +53,7 @@ filterwarnings = ignore:Using a non-boolean value for an isnull lookup is deprecated, use True or False instead.:django.utils.deprecation.RemovedInDjango40Warning # following is needed beceause of https://github.com/mozilla/mozilla-django-oidc/pull/371 ignore:django.conf.urls.url().*:django.utils.deprecation.RemovedInDjango40Warning - + ignore:.*is deprecated in favour of new moneyed.l10n.format_money.* [coverage:run] source=. diff --git a/timed/employment/__init__.py b/timed/employment/__init__.py index 76788cbf1..e69de29bb 100644 --- a/timed/employment/__init__.py +++ b/timed/employment/__init__.py @@ -1,3 +0,0 @@ -# noqa: D104 - -default_app_config = "timed.employment.apps.EmploymentConfig" diff --git a/timed/reports/tests/test_work_report.py b/timed/reports/tests/test_work_report.py index db88f671e..9830a65c2 100644 --- a/timed/reports/tests/test_work_report.py +++ b/timed/reports/tests/test_work_report.py @@ -93,7 +93,7 @@ def test_work_report_single_project( @pytest.mark.parametrize( "is_employed, status_code, expected", [ - (True, status.HTTP_200_OK, 4), + (True, status.HTTP_200_OK, 3), (False, status.HTTP_400_BAD_REQUEST, 1), ], ) diff --git a/timed/settings.py b/timed/settings.py index c00a29fa8..934c448e0 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -52,7 +52,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): INSTALLED_APPS = [ "timed.apps.TimedAdminConfig", "django.contrib.humanize", - "multiselectfield", + "multiselectfield.apps.MultiSelectFieldConfig", "django.forms", "django.contrib.auth", "django.contrib.contenttypes", @@ -61,9 +61,9 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django.contrib.staticfiles", "rest_framework", "django_filters", - "djmoney", + "djmoney.apps.MoneyConfig", "mozilla_django_oidc", - "django_prometheus", + "django_prometheus.apps.DjangoPrometheusConfig", "corsheaders", "nested_inline", "timed.employment", @@ -369,3 +369,4 @@ def parse_admins(admins): "DJANGO_DATA_UPLOAD_MAX_NUMBER_FIELDS", default=1000 ) CORS_ALLOWED_ORIGINS = env.list("DJANGO_CORS_ALLOWED_ORIGINS", default=[]) +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/timed/subscription/migrations/0005_alter_package_price_currency.py b/timed/subscription/migrations/0005_alter_package_price_currency.py new file mode 100644 index 000000000..c0f1e57cf --- /dev/null +++ b/timed/subscription/migrations/0005_alter_package_price_currency.py @@ -0,0 +1,334 @@ +# Generated by Django 3.2.13 on 2022-04-14 08:52 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("subscription", "0004_auto_20200407_2052"), + ] + + operations = [ + migrations.AlterField( + model_name="package", + name="price_currency", + field=djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghan Afghani"), + ("AFA", "Afghan Afghani (1927–2002)"), + ("ALL", "Albanian Lek"), + ("ALK", "Albanian Lek (1946–1965)"), + ("DZD", "Algerian Dinar"), + ("ADP", "Andorran Peseta"), + ("AOA", "Angolan Kwanza"), + ("AOK", "Angolan Kwanza (1977–1991)"), + ("AON", "Angolan New Kwanza (1990–2000)"), + ("AOR", "Angolan Readjusted Kwanza (1995–1999)"), + ("ARA", "Argentine Austral"), + ("ARS", "Argentine Peso"), + ("ARM", "Argentine Peso (1881–1970)"), + ("ARP", "Argentine Peso (1983–1985)"), + ("ARL", "Argentine Peso Ley (1970–1983)"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Florin"), + ("AUD", "Australian Dollar"), + ("ATS", "Austrian Schilling"), + ("AZN", "Azerbaijani Manat"), + ("AZM", "Azerbaijani Manat (1993–2006)"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("BDT", "Bangladeshi Taka"), + ("BBD", "Barbadian Dollar"), + ("BYN", "Belarusian Ruble"), + ("BYB", "Belarusian Ruble (1994–1999)"), + ("BYR", "Belarusian Ruble (2000–2016)"), + ("BEF", "Belgian Franc"), + ("BEC", "Belgian Franc (convertible)"), + ("BEL", "Belgian Franc (financial)"), + ("BZD", "Belize Dollar"), + ("BMD", "Bermudan Dollar"), + ("BTN", "Bhutanese Ngultrum"), + ("BOB", "Bolivian Boliviano"), + ("BOL", "Bolivian Boliviano (1863–1963)"), + ("BOV", "Bolivian Mvdol"), + ("BOP", "Bolivian Peso"), + ("BAM", "Bosnia-Herzegovina Convertible Mark"), + ("BAD", "Bosnia-Herzegovina Dinar (1992–1994)"), + ("BAN", "Bosnia-Herzegovina New Dinar (1994–1997)"), + ("BWP", "Botswanan Pula"), + ("BRC", "Brazilian Cruzado (1986–1989)"), + ("BRZ", "Brazilian Cruzeiro (1942–1967)"), + ("BRE", "Brazilian Cruzeiro (1990–1993)"), + ("BRR", "Brazilian Cruzeiro (1993–1994)"), + ("BRN", "Brazilian New Cruzado (1989–1990)"), + ("BRB", "Brazilian New Cruzeiro (1967–1986)"), + ("BRL", "Brazilian Real"), + ("GBP", "British Pound"), + ("BND", "Brunei Dollar"), + ("BGL", "Bulgarian Hard Lev"), + ("BGN", "Bulgarian Lev"), + ("BGO", "Bulgarian Lev (1879–1952)"), + ("BGM", "Bulgarian Socialist Lev"), + ("BUK", "Burmese Kyat"), + ("BIF", "Burundian Franc"), + ("XPF", "CFP Franc"), + ("KHR", "Cambodian Riel"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verdean Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("XAF", "Central African CFA Franc"), + ("CLE", "Chilean Escudo"), + ("CLP", "Chilean Peso"), + ("CLF", "Chilean Unit of Account (UF)"), + ("CNX", "Chinese People’s Bank Dollar"), + ("CNY", "Chinese Yuan"), + ("CNH", "Chinese Yuan (offshore)"), + ("COP", "Colombian Peso"), + ("COU", "Colombian Real Value Unit"), + ("KMF", "Comorian Franc"), + ("CDF", "Congolese Franc"), + ("CRC", "Costa Rican Colón"), + ("HRD", "Croatian Dinar"), + ("HRK", "Croatian Kuna"), + ("CUC", "Cuban Convertible Peso"), + ("CUP", "Cuban Peso"), + ("CYP", "Cypriot Pound"), + ("CZK", "Czech Koruna"), + ("CSK", "Czechoslovak Hard Koruna"), + ("DKK", "Danish Krone"), + ("DJF", "Djiboutian Franc"), + ("DOP", "Dominican Peso"), + ("NLG", "Dutch Guilder"), + ("XCD", "East Caribbean Dollar"), + ("DDM", "East German Mark"), + ("ECS", "Ecuadorian Sucre"), + ("ECV", "Ecuadorian Unit of Constant Value"), + ("EGP", "Egyptian Pound"), + ("GQE", "Equatorial Guinean Ekwele"), + ("ERN", "Eritrean Nakfa"), + ("EEK", "Estonian Kroon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBA", "European Composite Unit"), + ("XEU", "European Currency Unit"), + ("XBB", "European Monetary Unit"), + ("XBC", "European Unit of Account (XBC)"), + ("XBD", "European Unit of Account (XBD)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fijian Dollar"), + ("FIM", "Finnish Markka"), + ("FRF", "French Franc"), + ("XFO", "French Gold Franc"), + ("XFU", "French UIC-Franc"), + ("GMD", "Gambian Dalasi"), + ("GEK", "Georgian Kupon Larit"), + ("GEL", "Georgian Lari"), + ("DEM", "German Mark"), + ("GHS", "Ghanaian Cedi"), + ("GHC", "Ghanaian Cedi (1979–2007)"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("GRD", "Greek Drachma"), + ("GTQ", "Guatemalan Quetzal"), + ("GWP", "Guinea-Bissau Peso"), + ("GNF", "Guinean Franc"), + ("GNS", "Guinean Syli"), + ("GYD", "Guyanaese Dollar"), + ("HTG", "Haitian Gourde"), + ("HNL", "Honduran Lempira"), + ("HKD", "Hong Kong Dollar"), + ("HUF", "Hungarian Forint"), + ("IMP", "IMP"), + ("ISK", "Icelandic Króna"), + ("ISJ", "Icelandic Króna (1918–1981)"), + ("INR", "Indian Rupee"), + ("IDR", "Indonesian Rupiah"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IEP", "Irish Pound"), + ("ILS", "Israeli New Shekel"), + ("ILP", "Israeli Pound"), + ("ILR", "Israeli Shekel (1980–1985)"), + ("ITL", "Italian Lira"), + ("JMD", "Jamaican Dollar"), + ("JPY", "Japanese Yen"), + ("JOD", "Jordanian Dinar"), + ("KZT", "Kazakhstani Tenge"), + ("KES", "Kenyan Shilling"), + ("KWD", "Kuwaiti Dinar"), + ("KGS", "Kyrgystani Som"), + ("LAK", "Laotian Kip"), + ("LVL", "Latvian Lats"), + ("LVR", "Latvian Ruble"), + ("LBP", "Lebanese Pound"), + ("LSL", "Lesotho Loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("LTL", "Lithuanian Litas"), + ("LTT", "Lithuanian Talonas"), + ("LUL", "Luxembourg Financial Franc"), + ("LUC", "Luxembourgian Convertible Franc"), + ("LUF", "Luxembourgian Franc"), + ("MOP", "Macanese Pataca"), + ("MKD", "Macedonian Denar"), + ("MKN", "Macedonian Denar (1992–1993)"), + ("MGA", "Malagasy Ariary"), + ("MGF", "Malagasy Franc"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("MVR", "Maldivian Rufiyaa"), + ("MVP", "Maldivian Rupee (1947–1981)"), + ("MLF", "Malian Franc"), + ("MTL", "Maltese Lira"), + ("MTP", "Maltese Pound"), + ("MRU", "Mauritanian Ouguiya"), + ("MRO", "Mauritanian Ouguiya (1973–2017)"), + ("MUR", "Mauritian Rupee"), + ("MXV", "Mexican Investment Unit"), + ("MXN", "Mexican Peso"), + ("MXP", "Mexican Silver Peso (1861–1992)"), + ("MDC", "Moldovan Cupon"), + ("MDL", "Moldovan Leu"), + ("MCF", "Monegasque Franc"), + ("MNT", "Mongolian Tugrik"), + ("MAD", "Moroccan Dirham"), + ("MAF", "Moroccan Franc"), + ("MZE", "Mozambican Escudo"), + ("MZN", "Mozambican Metical"), + ("MZM", "Mozambican Metical (1980–2006)"), + ("MMK", "Myanmar Kyat"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillean Guilder"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("NIO", "Nicaraguan Córdoba"), + ("NIC", "Nicaraguan Córdoba (1988–1991)"), + ("NGN", "Nigerian Naira"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("OMR", "Omani Rial"), + ("PKR", "Pakistani Rupee"), + ("XPD", "Palladium"), + ("PAB", "Panamanian Balboa"), + ("PGK", "Papua New Guinean Kina"), + ("PYG", "Paraguayan Guarani"), + ("PEI", "Peruvian Inti"), + ("PEN", "Peruvian Sol"), + ("PES", "Peruvian Sol (1863–1965)"), + ("PHP", "Philippine Piso"), + ("XPT", "Platinum"), + ("PLN", "Polish Zloty"), + ("PLZ", "Polish Zloty (1950–1995)"), + ("PTE", "Portuguese Escudo"), + ("GWE", "Portuguese Guinea Escudo"), + ("QAR", "Qatari Rial"), + ("XRE", "RINET Funds"), + ("RHD", "Rhodesian Dollar"), + ("RON", "Romanian Leu"), + ("ROL", "Romanian Leu (1952–2006)"), + ("RUB", "Russian Ruble"), + ("RUR", "Russian Ruble (1991–1998)"), + ("RWF", "Rwandan Franc"), + ("SVC", "Salvadoran Colón"), + ("WST", "Samoan Tala"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("CSD", "Serbian Dinar (2002–2006)"), + ("SCR", "Seychellois Rupee"), + ("SLL", "Sierra Leonean Leone"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SKK", "Slovak Koruna"), + ("SIT", "Slovenian Tolar"), + ("SBD", "Solomon Islands Dollar"), + ("SOS", "Somali Shilling"), + ("ZAR", "South African Rand"), + ("ZAL", "South African Rand (financial)"), + ("KRH", "South Korean Hwan (1953–1962)"), + ("KRW", "South Korean Won"), + ("KRO", "South Korean Won (1945–1953)"), + ("SSP", "South Sudanese Pound"), + ("SUR", "Soviet Rouble"), + ("ESP", "Spanish Peseta"), + ("ESA", "Spanish Peseta (A account)"), + ("ESB", "Spanish Peseta (convertible account)"), + ("XDR", "Special Drawing Rights"), + ("LKR", "Sri Lankan Rupee"), + ("SHP", "St. Helena Pound"), + ("XSU", "Sucre"), + ("SDD", "Sudanese Dinar (1992–2007)"), + ("SDG", "Sudanese Pound"), + ("SDP", "Sudanese Pound (1957–1998)"), + ("SRD", "Surinamese Dollar"), + ("SRG", "Surinamese Guilder"), + ("SZL", "Swazi Lilangeni"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("STN", "São Tomé & Príncipe Dobra"), + ("STD", "São Tomé & Príncipe Dobra (1977–2017)"), + ("TVD", "TVD"), + ("TJR", "Tajikistani Ruble"), + ("TJS", "Tajikistani Somoni"), + ("TZS", "Tanzanian Shilling"), + ("XTS", "Testing Currency Code"), + ("THB", "Thai Baht"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TPE", "Timorese Escudo"), + ("TOP", "Tongan Paʻanga"), + ("TTD", "Trinidad & Tobago Dollar"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TRL", "Turkish Lira (1922–2005)"), + ("TMT", "Turkmenistani Manat"), + ("TMM", "Turkmenistani Manat (1993–2009)"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("USS", "US Dollar (Same day)"), + ("UGX", "Ugandan Shilling"), + ("UGS", "Ugandan Shilling (1966–1987)"), + ("UAH", "Ukrainian Hryvnia"), + ("UAK", "Ukrainian Karbovanets"), + ("AED", "United Arab Emirates Dirham"), + ("UYW", "Uruguayan Nominal Wage Index Unit"), + ("UYU", "Uruguayan Peso"), + ("UYP", "Uruguayan Peso (1975–1993)"), + ("UYI", "Uruguayan Peso (Indexed Units)"), + ("UZS", "Uzbekistani Som"), + ("VUV", "Vanuatu Vatu"), + ("VES", "Venezuelan Bolívar"), + ("VEB", "Venezuelan Bolívar (1871–2008)"), + ("VEF", "Venezuelan Bolívar (2008–2018)"), + ("VND", "Vietnamese Dong"), + ("VNN", "Vietnamese Dong (1978–1985)"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("XOF", "West African CFA Franc"), + ("YDD", "Yemeni Dinar"), + ("YER", "Yemeni Rial"), + ("YUN", "Yugoslavian Convertible Dinar (1990–1992)"), + ("YUD", "Yugoslavian Hard Dinar (1966–1990)"), + ("YUM", "Yugoslavian New Dinar (1994–2002)"), + ("YUR", "Yugoslavian Reformed Dinar (1992–1993)"), + ("ZWN", "ZWN"), + ("ZRN", "Zairean New Zaire (1993–1998)"), + ("ZRZ", "Zairean Zaire (1971–1993)"), + ("ZMW", "Zambian Kwacha"), + ("ZMK", "Zambian Kwacha (1968–2012)"), + ("ZWD", "Zimbabwean Dollar (1980–2008)"), + ("ZWR", "Zimbabwean Dollar (2008)"), + ("ZWL", "Zimbabwean Dollar (2009)"), + ], + default="CHF", + editable=False, + max_length=3, + ), + ), + ] From 257e2aeedd36a112018bdedaf32191eaf0100420 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 4 Apr 2022 11:38:26 +0200 Subject: [PATCH 896/980] chore(reports): update workreport meta data --- timed/reports/templates/workreport.ots | Bin 160015 -> 160011 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/timed/reports/templates/workreport.ots b/timed/reports/templates/workreport.ots index a81a5af7b9b9197b07fdfc71f9142635abd7f282..304c21ecf016348a0d20439ec491795273e8466b 100644 GIT binary patch delta 4940 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 K^N}OuhWAL!<<85NU?)xWX^) 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 Date: Mon, 13 Jun 2022 09:36:37 +0200 Subject: [PATCH 897/980] chore(deps)!: update dependencies, rename field rename field type to absence_type in absence model as field name type is deprecated since DRF-JSON-API version 4.3.0 https://github.com/django-json-api/django-rest-framework-json-api/blob/main/CHANGELOG.md#430---2021-12-10 --- requirements-dev.txt | 21 ++++----- requirements.txt | 28 ++++++------ timed/employment/models.py | 4 +- timed/employment/serializers.py | 6 +-- .../employment/tests/test_absence_balance.py | 12 ++++-- timed/employment/tests/test_user.py | 2 +- timed/mixins.py | 4 +- .../0005_alter_package_price_currency.py | 2 +- timed/tracking/factories.py | 2 +- .../0014_rename_type_absence_absence_type.py | 18 ++++++++ timed/tracking/models.py | 4 +- timed/tracking/serializers.py | 16 +++---- timed/tracking/tests/test_absence.py | 43 +++++++++++++------ timed/tracking/views.py | 4 +- 14 files changed, 102 insertions(+), 64 deletions(-) create mode 100644 timed/tracking/migrations/0014_rename_type_absence_absence_type.py diff --git a/requirements-dev.txt b/requirements-dev.txt index 6e91530dc..a0691924c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,24 +1,25 @@ -r requirements.txt black==22.3.0 -coverage==5.5 +coverage==6.4.1 factory-boy==3.2.1 -flake8==3.9.2 -flake8-blind-except==0.2.0 -flake8-debugger==4.0.0 +flake8==4.0.1 +flake8-blind-except==0.2.1 +flake8-debugger==4.1.2 flake8-deprecated==1.3 flake8-docstrings==1.6.0 flake8-isort==4.1.1 flake8-string-format==0.3.0 ipdb==0.13.9 -isort==5.8.0 -pdbpp==0.10.2 -pytest==6.2.4 -pytest-cov==2.12.1 +isort==5.10.1 +pdbpp==0.10.3 +pytest==7.1.2 +pytest-cov==3.0.0 pytest-django==4.5.2 pytest-env==0.6.2 +# needs to stay at 2.1.0 because of wrong interpretation of parameters with "__" pytest-factoryboy==2.1.0 pytest-freezegun==0.4.2 -pytest-mock==3.6.1 -pytest-randomly==3.8.0 +pytest-mock==3.7.0 +pytest-randomly==3.12.0 requests-mock==1.9.3 snapshottest==0.6.0 diff --git a/requirements.txt b/requirements.txt index b3c6860ba..456c0f0d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,25 @@ -python-dateutil==2.8.1 +python-dateutil==2.8.2 django==3.2.13 # might remove this once we find out how the jsonapi extras_require work -django-cors-headers==3.10.1 -django-filter==2.4.0 +django-cors-headers==3.13.0 +django-filter==21.1 django-multiselectfield==0.1.12 -django-prometheus==2.1.0 -djangorestframework==3.12.4 -djangorestframework-jsonapi[django-filter]==3.1.0 +django-prometheus==2.2.0 +djangorestframework==3.13.1 +djangorestframework-jsonapi[django-filter]==5.0.0 mozilla-django-oidc==2.0.0 -psycopg2==2.8.6 -pytz==2021.1 +psycopg2==2.9.3 +pytz==2022.1 pyexcel-webio==0.1.4 -pyexcel-io==0.6.4 +pyexcel-io==0.6.6 django-excel==0.0.10 -django-nested-inline==0.4.4 -pyexcel-ods3==0.6.0 +django-nested-inline==0.4.5 +pyexcel-ods3==0.6.1 pyexcel-xlsx==0.6.0 pyexcel-ezodf==0.3.4 -django-environ==0.4.5 +django-environ==0.8.1 django-money==2.1.1 python-redmine==2.3.0 -sentry-sdk==1.5.1 +sentry-sdk==1.5.12 gunicorn==20.1.0 -whitenoise==5.3.0 +whitenoise==6.2.0 diff --git a/timed/employment/models.py b/timed/employment/models.py index 02d911391..8e54db546 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -112,7 +112,7 @@ def calculate_used_days(self, user, start, end): return None absences = Absence.objects.filter( - user=user, type=self, date__range=[start, end] + user=user, absence_type=self, date__range=[start, end] ) used_days = absences.count() return used_days @@ -301,7 +301,7 @@ def calculate_worktime(self, start, end): absence.calculate_duration(self) for absence in Absence.objects.filter( user=self.user_id, date__gte=start, date__lte=end - ).select_related("type") + ).select_related("absence_type") ], timedelta(), ) diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index ea1071f25..3f0898613 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -114,7 +114,7 @@ class AbsenceBalanceSerializer(Serializer): ) absence_credits = relations.SerializerMethodResourceRelatedField( - source="get_absence_credits", + method_name="get_absence_credits", model=models.AbsenceCredit, many=True, read_only=True, @@ -183,8 +183,8 @@ def get_used_duration(self, instance): for absence in Absence.objects.filter( user=instance.user, date__range=[start, instance.date], - type_id=instance.id, - ).select_related("type") + absence_type_id=instance.id, + ).select_related("absence_type") ], timedelta(), ) diff --git a/timed/employment/tests/test_absence_balance.py b/timed/employment/tests/test_absence_balance.py index 8111777a6..df7760435 100644 --- a/timed/employment/tests/test_absence_balance.py +++ b/timed/employment/tests/test_absence_balance.py @@ -24,9 +24,11 @@ def test_absence_balance_full_day(auth_client, django_assert_num_queries): # credit on different user, may not show up AbsenceCreditFactory.create(date=date.today(), absence_type=absence_type) - AbsenceFactory.create(date=day, user=user, type=absence_type) + AbsenceFactory.create(date=day, user=user, absence_type=absence_type) - AbsenceFactory.create(date=day - timedelta(days=1), user=user, type=absence_type) + AbsenceFactory.create( + date=day - timedelta(days=1), user=user, absence_type=absence_type + ) url = reverse("absence-balance-list") @@ -68,9 +70,11 @@ def test_absence_balance_fill_worktime(auth_client, django_assert_num_queries): user=user, date=day + timedelta(days=1), duration=timedelta(hours=4) ) - AbsenceFactory.create(date=day + timedelta(days=1), user=user, type=absence_type) + AbsenceFactory.create( + date=day + timedelta(days=1), user=user, absence_type=absence_type + ) - AbsenceFactory.create(date=day, user=user, type=absence_type) + AbsenceFactory.create(date=day, user=user, absence_type=absence_type) url = reverse("absence-balance-list") with django_assert_num_queries(11): diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index 8fa1a37a4..c5a6653dd 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -180,7 +180,7 @@ def test_user_transfer(superadmin_client): AbsenceTypeFactory.create(fill_worktime=True) AbsenceTypeFactory.create(fill_worktime=False) absence_type = AbsenceTypeFactory.create(fill_worktime=False) - AbsenceFactory.create(user=user, type=absence_type, date=date(2017, 12, 29)) + AbsenceFactory.create(user=user, absence_type=absence_type, date=date(2017, 12, 29)) url = reverse("user-transfer", args=[user.id]) response = superadmin_client.post(url) diff --git a/timed/mixins.py b/timed/mixins.py index 4ccdbe371..38079632d 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -37,10 +37,10 @@ def _is_related_field(self, val): Ignores serializer method fields which define logic separately. """ return isinstance(val, relations.ResourceRelatedField) and not isinstance( - val, relations.SerializerMethodResourceRelatedField + val, relations.ManySerializerMethodResourceRelatedField ) - def get_serializer(self, data, *args, **kwargs): + def get_serializer(self, data=None, *args, **kwargs): # no data no wrapping needed if not data: return super().get_serializer(data, *args, **kwargs) diff --git a/timed/subscription/migrations/0005_alter_package_price_currency.py b/timed/subscription/migrations/0005_alter_package_price_currency.py index c0f1e57cf..866a13a70 100644 --- a/timed/subscription/migrations/0005_alter_package_price_currency.py +++ b/timed/subscription/migrations/0005_alter_package_price_currency.py @@ -219,7 +219,7 @@ class Migration(migrations.Migration): ("PEI", "Peruvian Inti"), ("PEN", "Peruvian Sol"), ("PES", "Peruvian Sol (1863–1965)"), - ("PHP", "Philippine Piso"), + ("PHP", "Philippine Peso"), ("XPT", "Platinum"), ("PLN", "Polish Zloty"), ("PLZ", "Polish Zloty (1950–1995)"), diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 46e43bbc3..7e232d810 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -79,7 +79,7 @@ class AbsenceFactory(DjangoModelFactory): """Absence factory.""" user = SubFactory("timed.employment.factories.UserFactory") - type = SubFactory("timed.employment.factories.AbsenceTypeFactory") + absence_type = SubFactory("timed.employment.factories.AbsenceTypeFactory") date = Faker("date") class Meta: diff --git a/timed/tracking/migrations/0014_rename_type_absence_absence_type.py b/timed/tracking/migrations/0014_rename_type_absence_absence_type.py new file mode 100644 index 000000000..00b9efc43 --- /dev/null +++ b/timed/tracking/migrations/0014_rename_type_absence_absence_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-16 13:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("tracking", "0013_report_billed"), + ] + + operations = [ + migrations.RenameField( + model_name="absence", + old_name="type", + new_name="absence_type", + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index f18ec31a6..94ba8d339 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -151,7 +151,7 @@ class Absence(models.Model): comment = models.TextField(blank=True) date = models.DateField() - type = models.ForeignKey( + absence_type = models.ForeignKey( "employment.AbsenceType", on_delete=models.PROTECT, related_name="absences" ) user = models.ForeignKey( @@ -167,7 +167,7 @@ def calculate_duration(self, employment): for absences which need to fill day calcuation needs to check how much time has been reported on that day. """ - if not self.type.fill_worktime: + if not self.absence_type.fill_worktime: return employment.worktime_per_day reports = Report.objects.filter(date=self.date, user=self.user_id) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index e2a43d060..ba9ee4ac6 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -267,16 +267,16 @@ class ReportIntersectionSerializer(Serializer): """ customer = relations.SerializerMethodResourceRelatedField( - source="get_customer", model=Customer, read_only=True + method_name="get_customer", model=Customer, read_only=True ) project = relations.SerializerMethodResourceRelatedField( - source="get_project", model=Project, read_only=True + method_name="get_project", model=Project, read_only=True ) task = relations.SerializerMethodResourceRelatedField( - source="get_task", model=Task, read_only=True + method_name="get_task", model=Task, read_only=True ) user = relations.SerializerMethodResourceRelatedField( - source="get_user", model=User, read_only=True + method_name="get_user", model=User, read_only=True ) comment = SerializerMethodField() review = SerializerMethodField() @@ -356,12 +356,12 @@ class AbsenceSerializer(ModelSerializer): """Absence serializer.""" duration = SerializerMethodField(source="get_duration") - type = ResourceRelatedField(queryset=AbsenceType.objects.all()) + absence_type = ResourceRelatedField(queryset=AbsenceType.objects.all()) user = CurrentUserResourceRelatedField() included_serializers = { "user": "timed.employment.serializers.UserSerializer", - "type": "timed.employment.serializers.AbsenceTypeSerializer", + "absence_type": "timed.employment.serializers.AbsenceTypeSerializer", } def get_duration(self, instance): @@ -383,7 +383,7 @@ def validate_date(self, value): return value - def validate_type(self, value): + def validate_absence_type(self, value): """Only owner is allowed to change type.""" if self.instance is not None: user = self.context["request"].user @@ -425,4 +425,4 @@ class Meta: """Meta information for the absence serializer.""" model = models.Absence - fields = ["comment", "date", "duration", "type", "user"] + fields = ["comment", "date", "duration", "absence_type", "user"] diff --git a/timed/tracking/tests/test_absence.py b/timed/tracking/tests/test_absence.py index 25fa53bc8..e5dc07c15 100644 --- a/timed/tracking/tests/test_absence.py +++ b/timed/tracking/tests/test_absence.py @@ -116,7 +116,7 @@ def test_absence_create(auth_client, is_external, expected): employment = EmploymentFactory.create( user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) - type = AbsenceTypeFactory.create() + absence_type = AbsenceTypeFactory.create() if is_external: employment.is_external = True @@ -128,7 +128,9 @@ def test_absence_create(auth_client, is_external, expected): "id": None, "attributes": {"date": date.strftime("%Y-%m-%d")}, "relationships": { - "type": {"data": {"type": "absence-types", "id": type.id}} + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} + } }, } } @@ -200,7 +202,7 @@ def test_absence_update_superadmin_type(superadmin_client): """Test that superadmin may not change type of absence.""" user = UserFactory.create() date = datetime.date(2017, 5, 3) - type = AbsenceTypeFactory.create() + absence_type = AbsenceTypeFactory.create() absence = AbsenceFactory.create(user=user, date=datetime.date(2016, 5, 3)) EmploymentFactory.create( user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) @@ -210,8 +212,11 @@ def test_absence_update_superadmin_type(superadmin_client): "data": { "type": "absences", "id": absence.id, + "attributes": {"date": date.strftime("%Y-%m-%d")}, "relationships": { - "type": {"data": {"type": "absence-types", "id": type.id}} + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} + } }, } } @@ -249,7 +254,7 @@ def test_absence_fill_worktime(auth_client): EmploymentFactory.create( user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) - type = AbsenceTypeFactory.create(fill_worktime=True) + absence_type = AbsenceTypeFactory.create(fill_worktime=True) ReportFactory.create(user=user, date=date, duration=datetime.timedelta(hours=5)) @@ -259,7 +264,9 @@ def test_absence_fill_worktime(auth_client): "id": None, "attributes": {"date": date.strftime("%Y-%m-%d")}, "relationships": { - "type": {"data": {"type": "absence-types", "id": type.id}} + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} + } }, } } @@ -284,7 +291,7 @@ def test_absence_fill_worktime_reported_time_to_long(auth_client): EmploymentFactory.create( user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) - type = AbsenceTypeFactory.create(fill_worktime=True) + absence_type = AbsenceTypeFactory.create(fill_worktime=True) ReportFactory.create( user=user, date=date, duration=datetime.timedelta(hours=8, minutes=30) @@ -296,7 +303,9 @@ def test_absence_fill_worktime_reported_time_to_long(auth_client): "id": None, "attributes": {"date": date.strftime("%Y-%m-%d")}, "relationships": { - "type": {"data": {"type": "absence-types", "id": type.id}} + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} + } }, } } @@ -314,7 +323,7 @@ def test_absence_weekend(auth_client): """Should not be able to create an absence on a weekend.""" date = datetime.date(2017, 5, 14) user = auth_client.user - type = AbsenceTypeFactory.create() + absence_type = AbsenceTypeFactory.create() EmploymentFactory.create( user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) @@ -325,7 +334,9 @@ def test_absence_weekend(auth_client): "id": None, "attributes": {"date": date.strftime("%Y-%m-%d")}, "relationships": { - "type": {"data": {"type": "absence-types", "id": type.id}} + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} + } }, } } @@ -340,7 +351,7 @@ def test_absence_public_holiday(auth_client): """Should not be able to create an absence on a public holiday.""" date = datetime.date(2017, 5, 16) user = auth_client.user - type = AbsenceTypeFactory.create() + absence_type = AbsenceTypeFactory.create() employment = EmploymentFactory.create( user=user, start_date=date, worktime_per_day=datetime.timedelta(hours=8) ) @@ -352,7 +363,9 @@ def test_absence_public_holiday(auth_client): "id": None, "attributes": {"date": date.strftime("%Y-%m-%d")}, "relationships": { - "type": {"data": {"type": "absence-types", "id": type.id}} + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} + } }, } } @@ -365,7 +378,7 @@ def test_absence_public_holiday(auth_client): def test_absence_create_unemployed(auth_client): """Test creation of absence fails on unemployed day.""" - type = AbsenceTypeFactory.create() + absence_type = AbsenceTypeFactory.create() data = { "data": { @@ -373,7 +386,9 @@ def test_absence_create_unemployed(auth_client): "id": None, "attributes": {"date": "2017-05-16"}, "relationships": { - "type": {"data": {"type": "absence-types", "id": type.id}} + "absence_type": { + "data": {"type": "absence-types", "id": absence_type.id} + } }, } } diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 23293617d..180306f54 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -367,10 +367,10 @@ def get_queryset(self): """Get absences only for internal employees.""" user = self.request.user if user.is_superuser: - queryset = models.Absence.objects.select_related("type", "user") + queryset = models.Absence.objects.select_related("absence_type", "user") return queryset - queryset = models.Absence.objects.select_related("type", "user").filter( + queryset = models.Absence.objects.select_related("absence_type", "user").filter( Q(user=user) | Q(user__in=user.supervisees.all()) ) return queryset From 2895b09db0fa2aebf2156bf7a47f625c22bbf64e Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 21 Jun 2022 14:40:00 +0200 Subject: [PATCH 898/980] fix(dev-config): add first and last name to dev keycloak config --- dev-config/keycloak-config.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dev-config/keycloak-config.json b/dev-config/keycloak-config.json index 873829e85..f31c94fc1 100644 --- a/dev-config/keycloak-config.json +++ b/dev-config/keycloak-config.json @@ -367,6 +367,8 @@ "enabled" : true, "totp" : false, "emailVerified" : true, + "firstName" : "Admin", + "lastName" : "Strator", "credentials" : [ { "id" : "42fa428a-0ca4-4951-9a4e-cb632f8e3367", "type" : "password", @@ -389,6 +391,8 @@ "enabled" : true, "totp" : false, "emailVerified" : true, + "firstName" : "Axel", + "lastName" : "Schöni", "credentials" : [ { "id" : "ded43644-9163-4a1f-944f-0eb925896507", "type" : "password", @@ -411,6 +415,8 @@ "enabled" : true, "totp" : false, "emailVerified" : true, + "firstName" : "Fritz", + "lastName" : "Muster", "credentials" : [ { "id" : "a4822286-3490-497e-942b-96afbfc52fee", "type" : "password", From 8fbdf6837c3c97e91093d6c9bd6d100a5d0919a4 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 22 Jun 2022 14:31:27 +0200 Subject: [PATCH 899/980] chore(changelog): update missing changelog entries + prepare release v2.0.0 --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ timed/__init__.py | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ffbe740..ded5dc408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +# v2.0.0 + +### Breaking +* **tracking:** rename field type to absence_type ([`8ca44d2`](https://github.com/adfinis-sygroup/timed-backend/commit/8ca44d2f361228e7f71e3e28a795079a2e3e7745)) + +# v1.6.3 + +### Fix + +* **workreport:** Update metadata ([`257e2ae`](https://github.com/adfinis-sygroup/timed-backend/pull/855/commits/257e2aeedd36a112018bdedaf32191eaf0100420)) +* **deps:** Bump django from 3.1.14 to 3.2.13 ([`ca8b76d`](https://github.com/adfinis-sygroup/timed-backend/pull/856/commits/ca8b76dd2d1f2ce365595101bb4a6d53aa85994d)) + +# v1.6.2 + +### Fix + +* **tracking:** Allow updating of billed reports ([`e73e716`](https://github.com/adfinis-sygroup/timed-backend/pull/851/commits/e73e7161d51b93b14faa0a5f5babf740166aff06)) + +# v1.6.1 + +### Fix + +* **projects:** Change permissions and visibility for billing types ([`8a705db`](https://github.com/adfinis-sygroup/timed-backend/pull/847/commits/8a705dbca7a66abd443f0a99341004c3515f3dbd)) +* **subscription:** Fix parser and notifications for orders ([`0deaafa`](https://github.com/adfinis-sygroup/timed-backend/pull/849/commits/0deaafa71d8520c7bf17fc91aa938f0106f96150)) + +# v1.6.0 + +### Feature +* **env:** Add tls option for emails to env var ([`c68107a`](https://github.com/adfinis-sygroup/timed-backend/pull/845/commits/c68107a4a58f54fbaa2c1de2f158437ad78609f3)) + +### Fix +* **reports:** Add reviewer hierarchy in `notify_reviewers_unverified` ([`91751e9`](https://github.com/adfinis-sygroup/timed-backend/pull/843/commits/91751e9497ac67ecb3072e33a6c990169d8488ee)) +* **subscription:** Include cost center in `SubscriptionProjectSerializer` ([`11640f8`](https://github.com/adfinis-sygroup/timed-backend/pull/846/commits/11640f88d797480a5f110fc7fc9b27d262f22bfa)) + +# v1.5.5 + +### Fix +* **reports:** Center total hours column in workreport ([`1acd374`](https://github.com/adfinis-sygroup/timed-backend/pull/840/commits/1acd3742af972e17d8600b560f16f7afe9a70d1d)) + +# v1.5.4 + +### Fix +* **auth:** Username should be case insensitive ([`1ce24bd`](https://github.com/adfinis-sygroup/timed-backend/commit/1ce24bd04f4b217e560707bd699bbeb6fe14fe09)) + # v1.5.2 ### Fix diff --git a/timed/__init__.py b/timed/__init__.py index 5197c5f5a..8c0d5d5bb 100644 --- a/timed/__init__.py +++ b/timed/__init__.py @@ -1 +1 @@ -__version__ = "1.5.2" +__version__ = "2.0.0" From 0c3fa09322908f20a63db2561dd665036b5bea0f Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 19 Jul 2022 13:16:27 +0200 Subject: [PATCH 900/980] chore(ci): publish container image to ghcr.io --- .github/workflows/release.yaml | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..ca696917d --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,49 @@ +name: Release Container Image + +on: + pull_request: + push: + branches: main + tags: + - 'v*.*.*' + +jobs: + container: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: ghcr.io/adfinis-sygroup/timed-backend + flavor: | + latest=auto + labels: | + org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.description=${{ github.event.repository.description }} + org.opencontainers.image.url=${{ github.event.repository.html_url }} + org.opencontainers.image.source=${{ github.event.repository.clone_url }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} + + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + if: ${{ github.event_name != 'pull_request' }} + + - name: Build and push + id: docker_build_ghcr + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: | + ${{ steps.meta.outputs.labels }} From 2cf346399f0fa8a1c6a7213798bf4d5d11df251c Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 20 Jul 2022 00:56:45 +0200 Subject: [PATCH 901/980] chore(ci): fix building on legacy default branch --- .github/workflows/release.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ca696917d..b074ed0e1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,7 +3,9 @@ name: Release Container Image on: pull_request: push: - branches: main + branches: + - main + - master tags: - 'v*.*.*' From a4e8983265d0b87101a6151982fbb8a802e4cd9a Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 24 Jun 2022 11:17:33 +0200 Subject: [PATCH 902/980] feat(tracking): reject reports reviewers can reject reports, if they were reported on the wrong task, project or customer send mails when a report was rejected only reviewer may reject reports automatically un-reject reports when they were moved by the owner --- timed/conftest.py | 1 + timed/tracking/factories.py | 1 + timed/tracking/filters.py | 1 + .../migrations/0015_report_rejected.py | 18 +++ timed/tracking/models.py | 13 ++ timed/tracking/serializers.py | 11 ++ timed/tracking/tasks.py | 30 ++++- .../mail/notify_user_rejected_reports.tmpl | 11 ++ timed/tracking/tests/test_report.py | 121 ++++++++++++++++++ timed/tracking/views.py | 11 +- 10 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 timed/tracking/migrations/0015_report_rejected.py create mode 100644 timed/tracking/templates/mail/notify_user_rejected_reports.tmpl diff --git a/timed/conftest.py b/timed/conftest.py index 2f431b5c7..31b21912f 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -82,6 +82,7 @@ def internal_employee(db): password="123qweasd", first_name="Test", last_name="User", + email="test@example.com", is_superuser=False, is_staff=False, ) diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index 7e232d810..f4055cd1e 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -31,6 +31,7 @@ class ReportFactory(DjangoModelFactory): review = False not_billable = False billed = False + rejected = False task = SubFactory("timed.projects.factories.TaskFactory") user = SubFactory("timed.employment.factories.UserFactory") diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index ebce4a127..0af928d53 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -98,6 +98,7 @@ class ReportFilterSet(FilterSet): billing_type = NumberFilter(field_name="task__project__billing_type") user = NumberFilter(field_name="user_id") cost_center = NumberFilter(method="filter_cost_center") + rejected = NumberFilter(field_name="rejected") def filter_has_reviewer(self, queryset, name, value): if not value: # pragma: no cover diff --git a/timed/tracking/migrations/0015_report_rejected.py b/timed/tracking/migrations/0015_report_rejected.py new file mode 100644 index 000000000..91fc30d51 --- /dev/null +++ b/timed/tracking/migrations/0015_report_rejected.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-24 08:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tracking", "0014_rename_type_absence_absence_type"), + ] + + operations = [ + migrations.AddField( + model_name="report", + name="rejected", + field=models.BooleanField(default=False), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 94ba8d339..78af8f057 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -4,6 +4,8 @@ from django.conf import settings from django.db import models +from django.db.models.signals import pre_save +from django.dispatch import receiver class Activity(models.Model): @@ -100,6 +102,7 @@ class Report(models.Model): ) added = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + rejected = models.BooleanField(default=False) def save(self, *args, **kwargs): """Save the report with some custom functionality. @@ -184,3 +187,13 @@ class Meta: """Meta informations for the absence model.""" unique_together = ("date", "user") + + +@receiver(pre_save, sender=Report) +def update_rejected_on_reports(sender, instance, **kwargs): + """Unreject report when the task changes.""" + # Check if the report is being created or updated + if instance.pk and instance.rejected: + report = Report.objects.get(id=instance.id) + if report.task_id != instance.task_id: + instance.rejected = False diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index ba9ee4ac6..baa153ae8 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -127,6 +127,15 @@ def validate_billed(self, value): return value + def validate_rejected(self, value): + """Only reviewers are allowed to change rejected field.""" + if self.instance is not None: + user = self.context["request"].user + if not user.is_reviewer and (self.instance.rejected != value): + raise ValidationError(_("Only reviewers may reject reports.")) + + return value + def validate(self, data): """ Validate that verified by is only set by reviewer or superuser. @@ -236,6 +245,7 @@ class Meta: "activity", "user", "verified_by", + "rejected", ] @@ -250,6 +260,7 @@ class ReportBulkSerializer(Serializer): not_billable = serializers.BooleanField(required=False, allow_null=True) billed = serializers.BooleanField(required=False, allow_null=True) verified = serializers.BooleanField(required=False, allow_null=True) + rejected = serializers.BooleanField(required=False) class Meta: resource_name = "report-bulks" diff --git a/timed/tracking/tasks.py b/timed/tracking/tasks.py index 1f29766c4..7422defb7 100644 --- a/timed/tracking/tasks.py +++ b/timed/tracking/tasks.py @@ -2,13 +2,16 @@ from django.core.mail import EmailMessage, get_connection from django.template.loader import get_template -template = get_template("mail/notify_user_changed_reports.tmpl", using="text") - -def _send_notification_emails(changes, reviewer): +def _send_notification_emails(changes, reviewer, rejected=False): """Send email for each user.""" - subject = "[Timed] Your reports have been changed" + if rejected: + subject = "[Timed] Your reports have been rejected" + template = get_template("mail/notify_user_rejected_reports.tmpl", using="text") + else: + template = get_template("mail/notify_user_changed_reports.tmpl", using="text") + subject = "[Timed] Your reports have been changed" from_email = settings.DEFAULT_FROM_EMAIL connection = get_connection() @@ -86,3 +89,22 @@ def notify_user_changed_reports(queryset, fields, reviewer): user_changes.append({"user": user, "changes": changes}) _send_notification_emails(user_changes, reviewer) + + +def notify_user_rejected_report(report, reviewer): + user_changes = {"user": report.user, "changes": [{"report": report}]} + _send_notification_emails([user_changes], reviewer, True) + + +def notify_user_rejected_reports(queryset, fields, reviewer): + users = [report.user for report in queryset.order_by("user").distinct("user")] + user_changes = [] + + for user in users: + changes = [] + for report in queryset.filter(user=user).order_by("date"): + changeset = {"report": report} + changes.append(changeset) + user_changes.append({"user": user, "changes": changes}) + + _send_notification_emails(user_changes, reviewer, True) diff --git a/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl b/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl new file mode 100644 index 000000000..4cd6e9792 --- /dev/null +++ b/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl @@ -0,0 +1,11 @@ +{% load tracking_extras %} +Some of your reports have been rejected. + +Reviewer: {{reviewer.first_name }} {{ reviewer.last_name }} +{% for changeset in user_changes %} + +Date: {{ changeset.report.date|date:"SHORT_DATE_FORMAT" }} +Duration: {{ changeset.report.duration|duration }} +Task: {{ changeset.report.task }} +Comment: {{ changeset.report.comment }} +---{% endfor %} diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 9d0d6b106..8fee6d0e8 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1683,3 +1683,124 @@ def test_report_list_no_employment( assert len(json["data"]) == expected assert json["data"][0]["id"] == str(report.id) assert json["meta"]["total-time"] == "01:00:00" + + +@pytest.mark.parametrize( + "report_owner, reviewer, expected, mail_count, status_code", + [ + (True, True, True, 1, status.HTTP_200_OK), + (False, True, True, 1, status.HTTP_200_OK), + (True, False, False, 0, status.HTTP_400_BAD_REQUEST), + (False, False, False, 0, status.HTTP_403_FORBIDDEN), + ], +) +def test_report_reject( + internal_employee_client, + report_owner, + report_factory, + reviewer, + expected, + status_code, + mail_count, + mailoutbox, +): + user = internal_employee_client.user + user2 = UserFactory.create() + report = report_factory.create(user=user2 if not report_owner else user) + if reviewer: + ProjectAssigneeFactory.create( + user=user, is_reviewer=True, project=report.task.project + ) + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {"rejected": True}, + } + } + + url = reverse("report-detail", args=[report.id]) + response = internal_employee_client.patch(url, data) + assert response.status_code == status_code + + report.refresh_from_db() + assert report.rejected == expected + + assert len(mailoutbox) == mail_count + + if mail_count: + mail = mailoutbox[0] + assert mail.to[0] == user2.email if not report_owner else user + + +def test_report_reject_multiple_notify( + internal_employee_client, + task, + task_factory, + project, + report_factory, + user_factory, + mailoutbox, +): + reviewer = internal_employee_client.user + ProjectAssigneeFactory.create(user=reviewer, project=project, is_reviewer=True) + + user1, user2, user3 = user_factory.create_batch(3) + report1_1 = report_factory(user=user1, task=task) + report1_2 = report_factory(user=user1, task=task) + report2 = report_factory(user=user2, task=task) + report3 = report_factory(user=user3, task=task) + + url = reverse("report-bulk") + + data = { + "data": { + "type": "report-bulks", + "id": None, + "attributes": {"rejected": True}, + } + } + + query_params = ( + "?editable=1" + f"&reviewer={reviewer.id}" + "&id=" + ",".join(str(r.id) for r in [report1_1, report1_2, report2, report3]) + ) + response = internal_employee_client.post(url + query_params, data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + for report in [report1_1, report1_2, report2, report3]: + report.refresh_from_db() + assert report.rejected + + # every user received one mail + assert len(mailoutbox) == 3 + assert all(True for mail in mailoutbox if len(mail.to) == 1) + assert set(mail.to[0] for mail in mailoutbox) == set( + user.email for user in [user1, user2, user3] + ) + + +def test_report_automatic_unreject(internal_employee_client, report_factory, task): + user = internal_employee_client.user + report = report_factory.create(user=user, rejected=True) + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": {"comment": "foo bar"}, + "relationships": { + "project": {"data": {"type": "projects", "id": task.project.id}}, + "task": {"data": {"type": "tasks", "id": task.id}}, + }, + } + } + + url = reverse("report-detail", args=[report.id]) + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.refresh_from_db() + assert not report.rejected diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 180306f54..c8e29f708 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -116,6 +116,7 @@ class ReportViewSet(ModelViewSet): "verified_by__username", "review", "not_billable", + "rejected", ) def get_queryset(self): @@ -172,6 +173,8 @@ def update(self, request, *args, **kwargs): } if fields and request.user != instance.user: tasks.notify_user_changed_report(instance, fields, request.user) + if "rejected" in fields: + tasks.notify_user_rejected_report(instance, request.user) return super().update(request, *args, **kwargs) @@ -264,8 +267,12 @@ def bulk(self, request): fields["billed"] = fields["task"].project.billed if fields: - tasks.notify_user_changed_reports(queryset, fields, user) - queryset.update(**fields) + if "rejected" in fields: + tasks.notify_user_rejected_reports(queryset, fields, user) + queryset.update(**fields) + else: + tasks.notify_user_changed_reports(queryset, fields, user) + queryset.update(**fields) return Response(status=status.HTTP_204_NO_CONTENT) From 0f44be97cdfda97b6f6788f52ae60e78c04465f3 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 4 Aug 2022 22:41:44 +0200 Subject: [PATCH 903/980] chore: rename github orga from adfinis-sygroup to adfinis --- .github/workflows/release.yaml | 2 +- CHANGELOG.md | 66 +++++++++++++++++----------------- CODEOWNERS | 2 +- CONTRIBUTING.md | 6 ++-- README.md | 10 +++--- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b074ed0e1..2c702dd85 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ jobs: id: meta uses: docker/metadata-action@v4 with: - images: ghcr.io/adfinis-sygroup/timed-backend + images: ghcr.io/adfinis/timed-backend flavor: | latest=auto labels: | diff --git a/CHANGELOG.md b/CHANGELOG.md index ded5dc408..d1331ced4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,57 +1,57 @@ # v2.0.0 ### Breaking -* **tracking:** rename field type to absence_type ([`8ca44d2`](https://github.com/adfinis-sygroup/timed-backend/commit/8ca44d2f361228e7f71e3e28a795079a2e3e7745)) +* **tracking:** rename field type to absence_type ([`8ca44d2`](https://github.com/adfinis/timed-backend/commit/8ca44d2f361228e7f71e3e28a795079a2e3e7745)) # v1.6.3 ### Fix -* **workreport:** Update metadata ([`257e2ae`](https://github.com/adfinis-sygroup/timed-backend/pull/855/commits/257e2aeedd36a112018bdedaf32191eaf0100420)) -* **deps:** Bump django from 3.1.14 to 3.2.13 ([`ca8b76d`](https://github.com/adfinis-sygroup/timed-backend/pull/856/commits/ca8b76dd2d1f2ce365595101bb4a6d53aa85994d)) +* **workreport:** Update metadata ([`257e2ae`](https://github.com/adfinis/timed-backend/pull/855/commits/257e2aeedd36a112018bdedaf32191eaf0100420)) +* **deps:** Bump django from 3.1.14 to 3.2.13 ([`ca8b76d`](https://github.com/adfinis/timed-backend/pull/856/commits/ca8b76dd2d1f2ce365595101bb4a6d53aa85994d)) # v1.6.2 ### Fix -* **tracking:** Allow updating of billed reports ([`e73e716`](https://github.com/adfinis-sygroup/timed-backend/pull/851/commits/e73e7161d51b93b14faa0a5f5babf740166aff06)) +* **tracking:** Allow updating of billed reports ([`e73e716`](https://github.com/adfinis/timed-backend/pull/851/commits/e73e7161d51b93b14faa0a5f5babf740166aff06)) # v1.6.1 ### Fix -* **projects:** Change permissions and visibility for billing types ([`8a705db`](https://github.com/adfinis-sygroup/timed-backend/pull/847/commits/8a705dbca7a66abd443f0a99341004c3515f3dbd)) -* **subscription:** Fix parser and notifications for orders ([`0deaafa`](https://github.com/adfinis-sygroup/timed-backend/pull/849/commits/0deaafa71d8520c7bf17fc91aa938f0106f96150)) +* **projects:** Change permissions and visibility for billing types ([`8a705db`](https://github.com/adfinis/timed-backend/pull/847/commits/8a705dbca7a66abd443f0a99341004c3515f3dbd)) +* **subscription:** Fix parser and notifications for orders ([`0deaafa`](https://github.com/adfinis/timed-backend/pull/849/commits/0deaafa71d8520c7bf17fc91aa938f0106f96150)) # v1.6.0 ### Feature -* **env:** Add tls option for emails to env var ([`c68107a`](https://github.com/adfinis-sygroup/timed-backend/pull/845/commits/c68107a4a58f54fbaa2c1de2f158437ad78609f3)) +* **env:** Add tls option for emails to env var ([`c68107a`](https://github.com/adfinis/timed-backend/pull/845/commits/c68107a4a58f54fbaa2c1de2f158437ad78609f3)) ### Fix -* **reports:** Add reviewer hierarchy in `notify_reviewers_unverified` ([`91751e9`](https://github.com/adfinis-sygroup/timed-backend/pull/843/commits/91751e9497ac67ecb3072e33a6c990169d8488ee)) -* **subscription:** Include cost center in `SubscriptionProjectSerializer` ([`11640f8`](https://github.com/adfinis-sygroup/timed-backend/pull/846/commits/11640f88d797480a5f110fc7fc9b27d262f22bfa)) +* **reports:** Add reviewer hierarchy in `notify_reviewers_unverified` ([`91751e9`](https://github.com/adfinis/timed-backend/pull/843/commits/91751e9497ac67ecb3072e33a6c990169d8488ee)) +* **subscription:** Include cost center in `SubscriptionProjectSerializer` ([`11640f8`](https://github.com/adfinis/timed-backend/pull/846/commits/11640f88d797480a5f110fc7fc9b27d262f22bfa)) # v1.5.5 ### Fix -* **reports:** Center total hours column in workreport ([`1acd374`](https://github.com/adfinis-sygroup/timed-backend/pull/840/commits/1acd3742af972e17d8600b560f16f7afe9a70d1d)) +* **reports:** Center total hours column in workreport ([`1acd374`](https://github.com/adfinis/timed-backend/pull/840/commits/1acd3742af972e17d8600b560f16f7afe9a70d1d)) # v1.5.4 ### Fix -* **auth:** Username should be case insensitive ([`1ce24bd`](https://github.com/adfinis-sygroup/timed-backend/commit/1ce24bd04f4b217e560707bd699bbeb6fe14fe09)) +* **auth:** Username should be case insensitive ([`1ce24bd`](https://github.com/adfinis/timed-backend/commit/1ce24bd04f4b217e560707bd699bbeb6fe14fe09)) # v1.5.2 ### Fix -* **subscription/notify_admin:** Prevent invalid addition of datetime and int ([`645881d`](https://github.com/adfinis-sygroup/timed-backend/pull/829/commits/645881d22aa7987614a13e7ee62a8f201b60c717)) +* **subscription/notify_admin:** Prevent invalid addition of datetime and int ([`645881d`](https://github.com/adfinis/timed-backend/pull/829/commits/645881d22aa7987614a13e7ee62a8f201b60c717)) # v1.5.1 ### Fix -* **subscription/notify_admin:** Check project.estimate before calcualting total_hours ([`63273d2`](https://github.com/adfinis-sygroup/timed-backend/commit/63273d27e9c57714ba9c01c9870a6949cfd33e91)) -* **subscriptions/notify_admin:** Use dateutils parser to prevent an error ([`c3a8c6c`](https://github.com/adfinis-sygroup/timed-backend/commit/c3a8c6ceb708efd309f79c6f9808231e2169dea4)) +* **subscription/notify_admin:** Check project.estimate before calcualting total_hours ([`63273d2`](https://github.com/adfinis/timed-backend/commit/63273d27e9c57714ba9c01c9870a6949cfd33e91)) +* **subscriptions/notify_admin:** Use dateutils parser to prevent an error ([`c3a8c6c`](https://github.com/adfinis/timed-backend/commit/c3a8c6ceb708efd309f79c6f9808231e2169dea4)) # v1.5.0 @@ -130,34 +130,34 @@ Add manager role to project assignees #779 # v1.3.0 (12 August 2021) ### Feature -* Use assignees with reviewer role instead of reviewers ([`89def71`](https://github.com/adfinis-sygroup/timed-backend/commit/89def71eefc0f18e7989b34f882acd2fd619998d)) -* Rewrite permissions and visibilty to use with assignees and external employees ([`159e750`](https://github.com/adfinis-sygroup/timed-backend/commit/159e75033ed4c477d56f2a2817dee82b3066d2a9)) -* Add user assignement to customers, projects and tasks ([`6ff4259`](https://github.com/adfinis-sygroup/timed-backend/commit/6ff425941307a0386d835187eaad02e26cc718e3)) -* Add and enable sentry-sdk for error reporting ([`1e96b78`](https://github.com/adfinis-sygroup/timed-backend/commit/1e96b785206ddd1a871e5b23a9126f50c94c38dc)) -* **employment:** Add new attribute is_external to employment model ([`e8e6291`](https://github.com/adfinis-sygroup/timed-backend/commit/e8e629193b7aabd592fc9744bc7210577d58c910)) -* **runtime:** Use gunicorn instead of uwsgi ([`e6b1fdf`](https://github.com/adfinis-sygroup/timed-backend/commit/e6b1fdfc5bb2ad5578ed2927ee210b5da2119f9b)) -* **redmine:** Update template formatting ([`9b1a6f1`](https://github.com/adfinis-sygroup/timed-backend/commit/9b1a6f164f72c2eae57a1e20cc0cff763c7e535a)) +* Use assignees with reviewer role instead of reviewers ([`89def71`](https://github.com/adfinis/timed-backend/commit/89def71eefc0f18e7989b34f882acd2fd619998d)) +* Rewrite permissions and visibilty to use with assignees and external employees ([`159e750`](https://github.com/adfinis/timed-backend/commit/159e75033ed4c477d56f2a2817dee82b3066d2a9)) +* Add user assignement to customers, projects and tasks ([`6ff4259`](https://github.com/adfinis/timed-backend/commit/6ff425941307a0386d835187eaad02e26cc718e3)) +* Add and enable sentry-sdk for error reporting ([`1e96b78`](https://github.com/adfinis/timed-backend/commit/1e96b785206ddd1a871e5b23a9126f50c94c38dc)) +* **employment:** Add new attribute is_external to employment model ([`e8e6291`](https://github.com/adfinis/timed-backend/commit/e8e629193b7aabd592fc9744bc7210577d58c910)) +* **runtime:** Use gunicorn instead of uwsgi ([`e6b1fdf`](https://github.com/adfinis/timed-backend/commit/e6b1fdfc5bb2ad5578ed2927ee210b5da2119f9b)) +* **redmine:** Update template formatting ([`9b1a6f1`](https://github.com/adfinis/timed-backend/commit/9b1a6f164f72c2eae57a1e20cc0cff763c7e535a)) ### Fix -* Update workreport template ([`b877194`](https://github.com/adfinis-sygroup/timed-backend/commit/b87719485affd6421734251c270d1fbeb37a7176)) +* Update workreport template ([`b877194`](https://github.com/adfinis/timed-backend/commit/b87719485affd6421734251c270d1fbeb37a7176)) # v1.2.0 (16 April 2021) ### Feature -* Export metrics with django-prometheus ([`6ed9cab`](https://github.com/adfinis-sygroup/timed-backend/commit/6ed9cabeeefd2e6945a63b83de1ee85018fb56a5)) -* Show not_billable and review attributes for reports in weekly report ([`a02aca4`](https://github.com/adfinis-sygroup/timed-backend/commit/a02aca48ae609f9ac514238be723c056fa60754f)) -* Add customer_visible field to project serializer ([`2f12f86`](https://github.com/adfinis-sygroup/timed-backend/commit/2f12f86d6132c1362d7065ad0fd8cf89a4f4f377)) -* Add billed flag to project and tracking ([`fe41199`](https://github.com/adfinis-sygroup/timed-backend/commit/fe41199527e5ab37f23c715d844805b7d8944d64)) -* **projects:** Add currency fields to task and project ([`7266c34`](https://github.com/adfinis-sygroup/timed-backend/commit/7266c346236e9e0d1c83d9f84b99a4e782256ba4)) +* Export metrics with django-prometheus ([`6ed9cab`](https://github.com/adfinis/timed-backend/commit/6ed9cabeeefd2e6945a63b83de1ee85018fb56a5)) +* Show not_billable and review attributes for reports in weekly report ([`a02aca4`](https://github.com/adfinis/timed-backend/commit/a02aca48ae609f9ac514238be723c056fa60754f)) +* Add customer_visible field to project serializer ([`2f12f86`](https://github.com/adfinis/timed-backend/commit/2f12f86d6132c1362d7065ad0fd8cf89a4f4f377)) +* Add billed flag to project and tracking ([`fe41199`](https://github.com/adfinis/timed-backend/commit/fe41199527e5ab37f23c715d844805b7d8944d64)) +* **projects:** Add currency fields to task and project ([`7266c34`](https://github.com/adfinis/timed-backend/commit/7266c346236e9e0d1c83d9f84b99a4e782256ba4)) ### Fix -* Translate work report to English ([`7a87d93`](https://github.com/adfinis-sygroup/timed-backend/commit/7a87d935893dbc68fd59a4fb477691ad209b6a3b)) -* Add custom forms for supervisor and supervisee inlines ([`b92799d`](https://github.com/adfinis-sygroup/timed-backend/commit/b92799d66759479827cf11f958c12d55d9c8d5bd)) -* Add billable column and calculate not billable time ([`4184b76`](https://github.com/adfinis-sygroup/timed-backend/commit/4184b76c66b5233d7a568cc6e37d9112ae9d939f)) -* **tracking:** Set billed from project on report ([`d25e64f`](https://github.com/adfinis-sygroup/timed-backend/commit/d25e64fd4c898757acb565996173f460f636c6a6)) -* **tracking:** Update billed if not sent with request ([`62295ba`](https://github.com/adfinis-sygroup/timed-backend/commit/62295bac19f302fa45281a72edb09397e3cbc4c6)) -* Add test data users to keycloak config ([`082ef6e`](https://github.com/adfinis-sygroup/timed-backend/commit/082ef6e14a406a5d3b1a5f286007169689c0cb1b)) +* Translate work report to English ([`7a87d93`](https://github.com/adfinis/timed-backend/commit/7a87d935893dbc68fd59a4fb477691ad209b6a3b)) +* Add custom forms for supervisor and supervisee inlines ([`b92799d`](https://github.com/adfinis/timed-backend/commit/b92799d66759479827cf11f958c12d55d9c8d5bd)) +* Add billable column and calculate not billable time ([`4184b76`](https://github.com/adfinis/timed-backend/commit/4184b76c66b5233d7a568cc6e37d9112ae9d939f)) +* **tracking:** Set billed from project on report ([`d25e64f`](https://github.com/adfinis/timed-backend/commit/d25e64fd4c898757acb565996173f460f636c6a6)) +* **tracking:** Update billed if not sent with request ([`62295ba`](https://github.com/adfinis/timed-backend/commit/62295bac19f302fa45281a72edb09397e3cbc4c6)) +* Add test data users to keycloak config ([`082ef6e`](https://github.com/adfinis/timed-backend/commit/082ef6e14a406a5d3b1a5f286007169689c0cb1b)) # v1.1.2 (28 October 2020) diff --git a/CODEOWNERS b/CODEOWNERS index 7c8b8104e..66419d391 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,3 +1,3 @@ # Code owners for the Timed backend. We include our backend dev team here. # Since this is a split project (backend/frontend) it's rather simple -* @adfinis-sygroup/dev-backend +* @adfinis/dev-backend diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13f93f27e..f225aa913 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing -Contributions to Timed backend are very welcome! Best have a look at the open [issues](https://github.com/adfinis-sygroup/timed-backend) -and open a [GitHub pull request](https://github.com/adfinis-sygroup/timed-backend/compare). See instructions below how to setup development +Contributions to Timed backend are very welcome! Best have a look at the open [issues](https://github.com/adfinis/timed-backend) +and open a [GitHub pull request](https://github.com/adfinis/timed-backend/compare). See instructions below how to setup development environment. Before writing any code, best discuss your proposed change in a GitHub issue to see if the proposed change makes sense for the project. ## Setup development environment @@ -11,7 +11,7 @@ environment. Before writing any code, best discuss your proposed change in a Git To work on Timed backend you first need to clone ```bash -git clone https://github.com/adfinis-sygroup/timed-backend.git +git clone https://github.com/adfinis/timed-backend.git cd timed-backend ``` diff --git a/README.md b/README.md index 3c87c4330..f1d2a8b2a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Timed Backend -[![Build Status](https://github.com/adfinis-sygroup/timed-backend/workflows/Test/badge.svg)](https://github.com/adfinis-sygroup/timed-backend/actions?query=workflow%3A%22Test%22) -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/adfinis-sygroup/timed-backend/blob/master/setup.cfg) -[![Pyup](https://pyup.io/repos/github/adfinis-sygroup/timed-backend/shield.svg)](https://pyup.io/account/repos/github/adfinis-sygroup/timed-backend/) -[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/adfinis-sygroup/timed-backend) +[![Build Status](https://github.com/adfinis/timed-backend/workflows/Test/badge.svg)](https://github.com/adfinis/timed-backend/actions?query=workflow%3A%22Test%22) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/adfinis/timed-backend/blob/master/setup.cfg) +[![Pyup](https://pyup.io/repos/github/adfinis/timed-backend/shield.svg)](https://pyup.io/account/repos/github/adfinis/timed-backend/) +[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/adfinis/timed-backend) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) Timed timetracking software REST API built with Django @@ -28,7 +28,7 @@ Then just start the docker-compose setup: make start ``` -This brings up complete local installation, including our [Timed Frontend](https://github.com/adfinis-sygroup/timed-frontend) project. +This brings up complete local installation, including our [Timed Frontend](https://github.com/adfinis/timed-frontend) project. You can visit it at [http://timed.local](http://timed.local). From 3d045f21ed7fd2147b49c6190dc3e1474c69decb Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 20 Jul 2022 09:20:03 +0200 Subject: [PATCH 904/980] feat: track remaining effort on tasks remaining effort tracking enables better control for project management activate remaining effort tracking on project and all corresponding tasks set remaining effort on report and update values accordingly on task and project --- timed/employment/factories.py | 1 + timed/permissions.py | 19 ++++ timed/projects/factories.py | 1 + .../0015_remaining_effort_task_project.py | 29 ++++++ timed/projects/models.py | 5 + timed/projects/serializers.py | 37 ++++++- timed/projects/tests/test_project.py | 50 +++++++++- timed/projects/tests/test_task.py | 7 +- timed/projects/views.py | 16 ++- timed/reports/views.py | 10 +- timed/tracking/apps.py | 4 + timed/tracking/factories.py | 2 +- .../0016_report_remaining_effort.py | 19 ++++ timed/tracking/models.py | 13 +-- timed/tracking/serializers.py | 9 ++ timed/tracking/signals.py | 34 +++++++ timed/tracking/tests/test_report.py | 98 +++++++++++++++++++ 17 files changed, 327 insertions(+), 27 deletions(-) create mode 100644 timed/projects/migrations/0015_remaining_effort_task_project.py create mode 100644 timed/tracking/migrations/0016_report_remaining_effort.py create mode 100644 timed/tracking/signals.py diff --git a/timed/employment/factories.py b/timed/employment/factories.py index 7f14ebd64..2fd347ac7 100644 --- a/timed/employment/factories.py +++ b/timed/employment/factories.py @@ -57,6 +57,7 @@ class EmploymentFactory(DjangoModelFactory): percentage = Faker("random_int", min=50, max=100) start_date = Faker("date_object") end_date = None + is_external = False @lazy_attribute def worktime_per_day(self): diff --git a/timed/permissions.py b/timed/permissions.py index 90d9d129e..3db7e9168 100644 --- a/timed/permissions.py +++ b/timed/permissions.py @@ -251,6 +251,25 @@ def has_object_permission(self, request, view, obj): ) .exists() ) + elif isinstance(obj, projects_models.Project): + return ( + projects_models.Project.objects.filter(pk=obj.pk) + .filter( + Q( + tasks__task_assignees__user=user, + tasks__task_assignees__is_manager=True, + ) + | Q( + project_assignees__user=user, + project_assignees__is_manager=True, + ) + | Q( + customer__customer_assignees__user=user, + customer__customer_assignees__is_manager=True, + ) + ) + .exists() + ) else: # pragma: no cover raise RuntimeError("IsManager permission called on unsupported model") diff --git a/timed/projects/factories.py b/timed/projects/factories.py index e18e0820c..3da958e4a 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -49,6 +49,7 @@ class ProjectFactory(DjangoModelFactory): customer = SubFactory("timed.projects.factories.CustomerFactory") cost_center = SubFactory("timed.projects.factories.CostCenterFactory") billing_type = SubFactory("timed.projects.factories.BillingTypeFactory") + remaining_effort_tracking = False class Meta: """Meta informations for the project factory.""" diff --git a/timed/projects/migrations/0015_remaining_effort_task_project.py b/timed/projects/migrations/0015_remaining_effort_task_project.py new file mode 100644 index 000000000..de54af422 --- /dev/null +++ b/timed/projects/migrations/0015_remaining_effort_task_project.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.13 on 2022-08-04 11:36 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0014_add_is_customer_role_to_assignees"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="remaining_effort_tracking", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="project", + name="total_remaining_effort", + field=models.DurationField(default=datetime.timedelta(0)), + ), + migrations.AddField( + model_name="task", + name="most_recent_remaining_effort", + field=models.DurationField(blank=True, null=True), + ), + ] diff --git a/timed/projects/models.py b/timed/projects/models.py index a1b5b2d3b..7f962ac23 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -1,5 +1,7 @@ """Models for the projects app.""" +from datetime import timedelta + from django.conf import settings from django.db import models from django.db.models import Q @@ -111,6 +113,8 @@ class Project(models.Model): through="ProjectAssignee", related_name="assigned_to_projects", ) + remaining_effort_tracking = models.BooleanField(default=False) + total_remaining_effort = models.DurationField(default=timedelta(0)) def __str__(self): """Represent the model as a string. @@ -156,6 +160,7 @@ class Task(models.Model): through="TaskAssignee", related_name="assigned_to_tasks", ) + most_recent_remaining_effort = models.DurationField(blank=True, null=True) def __str__(self): """Represent the model as a string. diff --git a/timed/projects/serializers.py b/timed/projects/serializers.py index 9d7ff52b5..8a79c39c9 100644 --- a/timed/projects/serializers.py +++ b/timed/projects/serializers.py @@ -4,7 +4,7 @@ from django.db.models import Q, Sum from django.utils.duration import duration_string from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import ModelSerializer +from rest_framework_json_api.serializers import ModelSerializer, ValidationError from timed.projects import models from timed.tracking.models import Report @@ -67,6 +67,30 @@ def get_root_meta(self, resource, many): return {} + def validate_remaining_effort_tracking(self, value): + user = self.context["request"].user + project = self.instance + if not ( + user.is_superuser + or user.is_accountant + or models.Project.objects.filter( + Q( + project_assignees__user=user, + project_assignees__is_manager=True, + project_assignees__project=project, + ) + | Q( + customer__customer_assignees__user=user, + customer__customer_assignees__is_manager=True, + customer__customer_assignees__customer=project.customer, + ) + ).exists() + ): + raise ValidationError( + "Only managers, accountants and superuser may activate remaining effort tracking!" + ) + return value + class Meta: """Meta information for the project serializer.""" @@ -82,6 +106,8 @@ class Meta: "billing_type", "cost_center", "customer_visible", + "remaining_effort_tracking", + "total_remaining_effort", ] @@ -116,9 +142,11 @@ def validate(self, data): """ request = self.context["request"] user = request.user + # check if user is manager when updating a task if self.instance: if ( - models.Task.objects.filter(id=self.instance.id) + user.is_superuser + or models.Task.objects.filter(id=self.instance.id) .filter( Q( task_assignees__user=user, @@ -136,8 +164,10 @@ def validate(self, data): .exists() ): return data + # check if user is manager when creating a task elif ( - models.Project.objects.filter(pk=data["project"].id) + user.is_superuser + or models.Project.objects.filter(pk=data["project"].id) .filter( Q( project_assignees__user=user, @@ -163,6 +193,7 @@ class Meta: "archived", "project", "cost_center", + "most_recent_remaining_effort", ] diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 5914692f5..8f48d91c2 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -10,6 +10,8 @@ CustomerAssigneeFactory, ProjectAssigneeFactory, ProjectFactory, + TaskAssigneeFactory, + TaskFactory, ) from timed.projects.serializers import ProjectSerializer @@ -36,7 +38,7 @@ def test_project_list_include( url = reverse("project-list") - with django_assert_num_queries(2): + with django_assert_num_queries(5): response = internal_employee_client.get( url, data={"include": ",".join(ProjectSerializer.included_serializers.keys())}, @@ -96,21 +98,21 @@ def test_project_create(auth_client): url = reverse("project-list") response = auth_client.post(url) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert response.status_code == status.HTTP_403_FORBIDDEN def test_project_update(auth_client, project): url = reverse("project-detail", args=[project.id]) response = auth_client.patch(url) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert response.status_code == status.HTTP_403_FORBIDDEN def test_project_delete(auth_client, project): url = reverse("project-detail", args=[project.id]) response = auth_client.delete(url) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.parametrize("is_assigned, expected", [(True, 1), (False, 0)]) @@ -193,3 +195,43 @@ def test_project_list_no_employment(auth_client, project, is_customer, expected) json = response.json() assert len(json["data"]) == expected + + +@pytest.mark.parametrize( + "assignee_level, status_code", + [ + ("customer", status.HTTP_200_OK), + ("project", status.HTTP_200_OK), + ("task", status.HTTP_400_BAD_REQUEST), + (None, status.HTTP_403_FORBIDDEN), + ], +) +def test_project_activate_remaining_effort( + internal_employee_client, assignee_level, status_code +): + task = TaskFactory.create() + user = internal_employee_client.user + + if assignee_level == "customer": + CustomerAssigneeFactory( + user=user, customer=task.project.customer, is_manager=True + ) + elif assignee_level == "project": + ProjectAssigneeFactory(user=user, project=task.project, is_manager=True) + elif assignee_level == "task": + TaskAssigneeFactory(user=user, task=task, is_manager=True) + + data = { + "data": { + "type": "projects", + "id": task.project.id, + "attributes": { + "remaining_effort_tracking": True, + }, + } + } + + url = reverse("project-detail", args=[task.project.id]) + + response = internal_employee_client.patch(url, data) + assert response.status_code == status_code diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 69d7922b1..6d1b79f0c 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -76,9 +76,9 @@ def test_task_detail(internal_employee_client, task): ], ) def test_task_create( - auth_client, project, project_assignee, customer_assignee, expected + internal_employee_client, project, project_assignee, customer_assignee, expected ): - user = auth_client.user + user = internal_employee_client.user project_assignee.user = user project_assignee.save() if customer_assignee.is_manager: @@ -97,7 +97,7 @@ def test_task_create( "type": "tasks", } } - response = auth_client.post(url, data=data) + response = internal_employee_client.post(url, data=data) assert response.status_code == expected @@ -110,6 +110,7 @@ def test_task_create( (False, False, False, True, False, False, status.HTTP_403_FORBIDDEN), (False, False, False, False, True, False, status.HTTP_200_OK), (False, False, False, False, True, True, status.HTTP_403_FORBIDDEN), + (False, False, False, False, False, False, status.HTTP_403_FORBIDDEN), ], ) def test_task_update( diff --git a/timed/projects/views.py b/timed/projects/views.py index d05e77ca8..fb849776f 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -4,12 +4,14 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from timed.permissions import ( + IsAccountant, IsAuthenticated, IsCustomer, IsInternal, IsManager, IsReadOnly, IsSuperUser, + IsUpdateOnly, ) from timed.projects import filters, models, serializers @@ -108,7 +110,7 @@ def get_queryset(self): return models.CostCenter.objects.all() -class ProjectViewSet(ReadOnlyModelViewSet): +class ProjectViewSet(ModelViewSet): """Project view set.""" serializer_class = serializers.ProjectSerializer @@ -116,6 +118,16 @@ class ProjectViewSet(ReadOnlyModelViewSet): ordering_fields = ("customer__name", "name") ordering = "name" queryset = models.Project.objects.all() + permission_classes = [ + # superuser may edit all projects + IsSuperUser + # accountants may edit all projects + | IsAccountant + # managers may edit only assigned projects + | IsManager & IsUpdateOnly + # all authenticated users may read all tasks + | IsAuthenticated & IsReadOnly + ] def get_queryset(self): """Get only assigned projects, if an employee is external.""" @@ -149,6 +161,7 @@ class TaskViewSet(ModelViewSet): serializer_class = serializers.TaskSerializer filterset_class = filters.TaskFilterSet queryset = models.Task.objects.select_related("project", "cost_center") + ordering = "name" permission_classes = [ # superuser may edit all tasks IsSuperUser @@ -157,7 +170,6 @@ class TaskViewSet(ModelViewSet): # all authenticated users may read all tasks | IsAuthenticated & IsReadOnly ] - ordering = "name" def filter_queryset(self, queryset): """Specific filter queryset options.""" diff --git a/timed/reports/views.py b/timed/reports/views.py index e43a662f4..e6968d273 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -107,7 +107,10 @@ def get_queryset(self): queryset = Report.objects.all() queryset = queryset.values("task__project") queryset = queryset.annotate(duration=Sum("duration")) - queryset = queryset.annotate(pk=F("task__project")) + queryset = queryset.annotate( + pk=F("task__project"), + sum_remaining=F("task__project__total_remaining_effort"), + ) return queryset @@ -129,7 +132,10 @@ def get_queryset(self): queryset = Report.objects.all() queryset = queryset.values("task") queryset = queryset.annotate(duration=Sum("duration")) - queryset = queryset.annotate(pk=F("task")) + queryset = queryset.annotate( + pk=F("task"), + most_recent_remaining_effort=F("task__most_recent_remaining_effort"), + ) return queryset diff --git a/timed/tracking/apps.py b/timed/tracking/apps.py index 6a3c839d7..de9c48fb6 100644 --- a/timed/tracking/apps.py +++ b/timed/tracking/apps.py @@ -8,3 +8,7 @@ class TrackingConfig(AppConfig): name = "timed.tracking" label = "tracking" + + def ready(self): + # Implicitly connect signal handlers decorated with @receiver. + from . import signals # noqa: F401 diff --git a/timed/tracking/factories.py b/timed/tracking/factories.py index f4055cd1e..ce5371370 100644 --- a/timed/tracking/factories.py +++ b/timed/tracking/factories.py @@ -24,7 +24,7 @@ class Meta: class ReportFactory(DjangoModelFactory): - """Task factory.""" + """Report factory.""" comment = Faker("sentence") date = Faker("date") diff --git a/timed/tracking/migrations/0016_report_remaining_effort.py b/timed/tracking/migrations/0016_report_remaining_effort.py new file mode 100644 index 000000000..fbb2f801d --- /dev/null +++ b/timed/tracking/migrations/0016_report_remaining_effort.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.13 on 2022-08-02 12:38 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tracking", "0015_report_rejected"), + ] + + operations = [ + migrations.AddField( + model_name="report", + name="remaining_effort", + field=models.DurationField(default=datetime.timedelta(0)), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 78af8f057..f9c7adf1f 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -4,8 +4,6 @@ from django.conf import settings from django.db import models -from django.db.models.signals import pre_save -from django.dispatch import receiver class Activity(models.Model): @@ -103,6 +101,7 @@ class Report(models.Model): added = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) rejected = models.BooleanField(default=False) + remaining_effort = models.DurationField(default=timedelta(0)) def save(self, *args, **kwargs): """Save the report with some custom functionality. @@ -187,13 +186,3 @@ class Meta: """Meta informations for the absence model.""" unique_together = ("date", "user") - - -@receiver(pre_save, sender=Report) -def update_rejected_on_reports(sender, instance, **kwargs): - """Unreject report when the task changes.""" - # Check if the report is being created or updated - if instance.pk and instance.rejected: - report = Report.objects.get(id=instance.id) - if report.task_id != instance.task_id: - instance.rejected = False diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index baa153ae8..a56c7dd82 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -136,6 +136,14 @@ def validate_rejected(self, value): return value + def validate_remaining_effort(self, value): + """Only update remaining effort when tracking is active on the corresponding project.""" + if not self.instance.task.project.remaining_effort_tracking: + raise ValidationError( + "Remaining effort tracking is not active on this project!" + ) + return value + def validate(self, data): """ Validate that verified by is only set by reviewer or superuser. @@ -246,6 +254,7 @@ class Meta: "user", "verified_by", "rejected", + "remaining_effort", ] diff --git a/timed/tracking/signals.py b/timed/tracking/signals.py new file mode 100644 index 000000000..f7fd8df8d --- /dev/null +++ b/timed/tracking/signals.py @@ -0,0 +1,34 @@ +from django.db.models import Sum +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from timed.tracking.models import Report + + +@receiver(pre_save, sender=Report) +def update_rejected_on_reports(sender, instance, **kwargs): + """Unreject report when the task changes.""" + # Check if the report is being created or updated + if instance.pk and instance.rejected: + report = Report.objects.get(id=instance.id) + if report.task_id != instance.task_id: + instance.rejected = False + + +@receiver(post_save, sender=Report) +def update_most_recent_remaining_effort(sender, instance, **kwargs): + """Update remaining effort on task, if remaining effort tracking is active.""" + if instance.task.project.remaining_effort_tracking: + task = instance.task + last_report = task.reports.order_by("date").last() + if instance == last_report: + task.most_recent_remaining_effort = instance.remaining_effort + task.save() + project = task.project + total_remaining_effort = ( + project.tasks.all() + .aggregate(sum_remaining=Sum("most_recent_remaining_effort")) + .get("sum_remaining") + ) + project.total_remaining_effort = total_remaining_effort + project.save() diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 8fee6d0e8..7af5e0e9c 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1804,3 +1804,101 @@ def test_report_automatic_unreject(internal_employee_client, report_factory, tas report.refresh_from_db() assert not report.rejected + + +@pytest.mark.parametrize( + "is_external, remaining_effort_active, is_superuser, expected", + [ + (True, True, False, status.HTTP_403_FORBIDDEN), + (True, False, False, status.HTTP_403_FORBIDDEN), + (False, True, False, status.HTTP_200_OK), + (False, False, False, status.HTTP_400_BAD_REQUEST), + (False, False, True, status.HTTP_400_BAD_REQUEST), + (False, True, True, status.HTTP_200_OK), + ], +) +def test_report_set_remaining_effort( + auth_client, + is_external, + remaining_effort_active, + expected, + is_superuser, + report_factory, +): + user = auth_client.user + EmploymentFactory.create(user=user, is_external=is_external) + report = report_factory.create(user=user) + + if remaining_effort_active: + report.task.project.remaining_effort_tracking = True + report.task.project.save() + + if is_superuser: + user.is_superuser = True + user.save() + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": { + "comment": "foo bar", + "remaining_effort": "01:00:00", + }, + } + } + + url = reverse("report-detail", args=[report.id]) + + response = auth_client.patch(url, data) + assert response.status_code == expected + + +def test_report_remaining_effort_total( + internal_employee_client, + report_factory, +): + user = internal_employee_client.user + report = report_factory.create(user=user) + task_2 = TaskFactory.create(project=report.task.project) + report_2 = report_factory.create(user=user, task=task_2) + report.task.project.remaining_effort_tracking = True + report.task.project.save() + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": { + "remaining_effort": "01:00:00", + }, + } + } + + url = reverse("report-detail", args=[report.id]) + + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.task.refresh_from_db() + assert report.task.most_recent_remaining_effort == timedelta(hours=1) + assert report.task.project.total_remaining_effort == timedelta(hours=1) + + data = { + "data": { + "type": "reports", + "id": report_2.id, + "attributes": { + "remaining_effort": "03:00:00", + }, + } + } + + url = reverse("report-detail", args=[report_2.id]) + + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + task_2.refresh_from_db() + assert task_2.most_recent_remaining_effort == timedelta(hours=3) + assert task_2.project.total_remaining_effort == timedelta(hours=4) From 0245539e12c50c13cd58cf202697edf4aaa228f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Aug 2022 10:08:47 +0000 Subject: [PATCH 905/980] chore(deps): bump sentry-sdk from 1.5.12 to 1.9.5 Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.5.12 to 1.9.5. - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/1.5.12...1.9.5) --- updated-dependencies: - dependency-name: sentry-sdk dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 456c0f0d6..302de0b9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,6 @@ pyexcel-ezodf==0.3.4 django-environ==0.8.1 django-money==2.1.1 python-redmine==2.3.0 -sentry-sdk==1.5.12 +sentry-sdk==1.9.5 gunicorn==20.1.0 whitenoise==6.2.0 From 4c010542ce3b3544c3e87f1dd2ca7ff8ec4df245 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 16 Sep 2022 11:13:33 +0200 Subject: [PATCH 906/980] feat(admin): add searchable dropdowns for user lists in admin --- timed/employment/admin.py | 16 ++++++++++++++-- timed/projects/admin.py | 3 +++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/timed/employment/admin.py b/timed/employment/admin.py index 9e7c37157..436beae55 100644 --- a/timed/employment/admin.py +++ b/timed/employment/admin.py @@ -4,6 +4,7 @@ from django import forms from django.contrib import admin +from django.contrib.admin.widgets import AutocompleteSelect from django.contrib.auth.admin import UserAdmin from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -21,7 +22,11 @@ class SupervisorForm(forms.ModelForm): # Change the label of the supervisor through table attribute to_user to_user = forms.ModelChoiceField( - queryset=models.User.objects.all(), label=_("supervised by") + queryset=models.User.objects.all(), + label=_("supervised by"), + widget=AutocompleteSelect( + models.User.supervisors.through.to_user.field, admin_site=admin.site + ), ) class Meta: @@ -36,7 +41,11 @@ class SuperviseeForm(forms.ModelForm): # Change the label of the supervisor through table attribute from_user from_user = forms.ModelChoiceField( - queryset=models.User.objects.all(), label=_("supervising") + queryset=models.User.objects.all(), + label=_("supervising"), + widget=AutocompleteSelect( + models.User.supervisors.through.from_user.field, admin_site=admin.site + ), ) class Meta: @@ -47,6 +56,7 @@ class Meta: class SupervisorInline(admin.TabularInline): + autocomplete_fields = ["to_user"] form = SupervisorForm model = models.User.supervisors.through extra = 0 @@ -56,6 +66,7 @@ class SupervisorInline(admin.TabularInline): class SuperviseeInline(admin.TabularInline): + autocomplete_fields = ["from_user"] form = SuperviseeForm model = models.User.supervisors.through extra = 0 @@ -143,6 +154,7 @@ class UserAdmin(UserAdmin): AbsenceCreditInline, ] list_display = ("username", "first_name", "last_name", "is_staff", "is_active") + search_fields = ["username"] actions = [ "disable_users", diff --git a/timed/projects/admin.py b/timed/projects/admin.py index 98c7dfcf3..a1de3a669 100644 --- a/timed/projects/admin.py +++ b/timed/projects/admin.py @@ -13,16 +13,19 @@ class CustomerAssigneeInline(admin.TabularInline): + autocomplete_fields = ["user"] model = models.CustomerAssignee extra = 0 class ProjectAssigneeInline(NestedStackedInline): + autocomplete_fields = ["user"] model = models.ProjectAssignee extra = 0 class TaskAssigneeInline(NestedStackedInline): + autocomplete_fields = ["user"] model = models.TaskAssignee extra = 1 From 8a1b2723147c9775cb06abd090ec307610a2d254 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 16 Sep 2022 11:15:54 +0200 Subject: [PATCH 907/980] feat(employment): add is_external filter for user endpoint --- timed/employment/filters.py | 4 ++++ timed/employment/tests/test_user.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 56cc531d1..691cc4adf 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -46,6 +46,10 @@ class UserFilterSet(FilterSet): is_reviewer = NumberFilter(method="filter_is_reviewer") is_supervisor = NumberFilter(method="filter_is_supervisor") is_accountant = NumberFilter(field_name="is_accountant") + is_external = NumberFilter(method="filter_is_external") + + def filter_is_external(self, queryset, name, value): + return queryset.filter(employments__is_external=value) def filter_is_reviewer(self, queryset, name, value): if value: diff --git a/timed/employment/tests/test_user.py b/timed/employment/tests/test_user.py index c5a6653dd..136e62a21 100644 --- a/timed/employment/tests/test_user.py +++ b/timed/employment/tests/test_user.py @@ -205,6 +205,21 @@ def test_user_transfer(superadmin_client): assert absence_credit.comment == "Transfer 2017" +@pytest.mark.parametrize("value,expected", [(1, 2), (0, 2)]) +def test_user_is_external_filter(internal_employee_client, value, expected): + """Should filter users if they have an internal employment.""" + user = UserFactory.create() + user2, user3 = UserFactory.create_batch(2) + EmploymentFactory.create(is_external=False, user=user) + EmploymentFactory.create(is_external=True, user=user2) + EmploymentFactory.create(is_external=True, user=user3) + + response = internal_employee_client.get( + reverse("user-list"), {"is_external": value} + ) + assert len(response.json()["data"]) == expected + + @pytest.mark.parametrize("value,expected", [(1, 1), (0, 4)]) def test_user_is_reviewer_filter(internal_employee_client, value, expected): """Should filter users if they are a reviewer.""" From 21d36774816467977f6a45bab0641d7abf4d6ec5 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 15 Aug 2022 08:33:04 +0200 Subject: [PATCH 908/980] fix(reports): refactor statistics set queryset starting point from Reports to Task, so we can get Tasks with no Reports add new FilterSet for Customer-, Project- and TaskStatistics --- timed/reports/filters.py | 148 ++++++++++++++++++ timed/reports/serializers.py | 8 +- .../reports/tests/test_customer_statistic.py | 18 +-- timed/reports/tests/test_project_statistic.py | 4 +- timed/reports/tests/test_task_statistic.py | 10 +- timed/reports/views.py | 51 +++--- 6 files changed, 191 insertions(+), 48 deletions(-) create mode 100644 timed/reports/filters.py diff --git a/timed/reports/filters.py b/timed/reports/filters.py new file mode 100644 index 000000000..4ec31c704 --- /dev/null +++ b/timed/reports/filters.py @@ -0,0 +1,148 @@ +from django.db.models import Q +from django_filters.rest_framework import DateFilter, FilterSet, NumberFilter, BaseInFilter + +from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee +from timed.projects.models import Task + + +class TaskStatisticFilterSet(FilterSet): + """Filter set for the customer, project and task statistic endpoint.""" + + id = BaseInFilter() + from_date = DateFilter(field_name="reports__date", lookup_expr="gte") + to_date = DateFilter(field_name="reports__date", lookup_expr="lte") + project = NumberFilter(field_name="project") + customer = NumberFilter(field_name="project__customer") + review = NumberFilter(field_name="reports__review") + editable = NumberFilter(method="filter_editable") + not_billable = NumberFilter(field_name="reports__not_billable") + billed = NumberFilter(field_name="reports__billed") + verified = NumberFilter( + field_name="reports__verified_by_id", lookup_expr="isnull", exclude=True + ) + reviewer = NumberFilter(method="filter_has_reviewer") + verifier = NumberFilter(field_name="reports__verified_by") + billing_type = NumberFilter(field_name="project__billing_type") + user = NumberFilter(field_name="reports__user_id") + cost_center = NumberFilter(method="filter_cost_center") + rejected = NumberFilter(field_name="reports__rejected") + + + def filter_has_reviewer(self, queryset, name, value): + if not value: # pragma: no cover + return queryset + + # reports in which user is customer assignee and responsible reviewer + reports_customer_assignee_is_reviewer = queryset.filter( + Q( + project__customer_id__in=CustomerAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("customer_id") + ) + ).exclude( + Q( + project_id__in=ProjectAssignee.objects.filter( + is_reviewer=True + ).values("project_id") + ) + | Q( + id__in=TaskAssignee.objects.filter(is_reviewer=True).values( + "task_id" + ) + ) + ) + + # reports in which user is project assignee and responsible reviewer + reports_project_assignee_is_reviewer = queryset.filter( + Q( + project_id__in=ProjectAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("project_id") + ) + ).exclude( + Q( + id__in=TaskAssignee.objects.filter(is_reviewer=True).values( + "task_id" + ) + ) + ) + + # reports in which user task assignee and responsible reviewer + reports_task_assignee_is_reviewer = queryset.filter( + Q( + id__in=TaskAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("task_id") + ) + ) + + return ( + reports_customer_assignee_is_reviewer + | reports_project_assignee_is_reviewer + | reports_task_assignee_is_reviewer + ) + + def filter_editable(self, queryset, name, value): + """Filter reports whether they are editable by current user. + + When set to `1` filter all results to what is editable by current + user. If set to `0` to not editable. + """ + user = self.request.user + assignee_filter = ( + # avoid duplicates by using subqueries instead of joins + Q(reports__user__in=user.supervisees.values("id")) + | Q( + task_assignees__user=user, + task_assignees__is_reviewer=True, + ) + | Q( + project__project_assignees__user=user, + project__project_assignees__is_reviewer=True, + ) + | Q( + project__customer__customer_assignees__user=user, + project__customer__customer_assignees__is_reviewer=True, + ) + | Q(reports__user=user) + ) + unfinished_filter = Q(reports__verified_by__isnull=True) + editable_filter = assignee_filter & unfinished_filter + + if value: # editable + if user.is_superuser: + # superuser may edit all reports + return queryset + elif user.is_accountant: + return queryset.filter(unfinished_filter) + # only owner, reviewer or supervisor may change unverified reports + queryset = queryset.filter(editable_filter).distinct() + + return queryset + else: # not editable + if user.is_superuser: + # no reports which are not editable + return queryset.none() + elif user.is_accountant: + return queryset.exclude(unfinished_filter) + + queryset = queryset.exclude(editable_filter) + return queryset + + def filter_cost_center(self, queryset, name, value): + """ + Filter report by cost center. + + Cost center on task has higher priority over project cost + center. + """ + return queryset.filter( + Q(cost_center=value) + | Q(project__cost_center=value) & Q(cost_center__isnull=True) + ) + + class Meta: + """Meta information for the task statistic filter set.""" + + model = Task + fields = ["most_recent_remaining_effort"] \ No newline at end of file diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 8f3675955..8908c98c8 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -26,7 +26,7 @@ class Meta: class CustomerStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() customer = relations.ResourceRelatedField( - source="task__project__customer", model=Customer, read_only=True + source="project__customer", model=Customer, read_only=True ) included_serializers = {"customer": "timed.projects.serializers.CustomerSerializer"} @@ -38,7 +38,7 @@ class Meta: class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() project = relations.ResourceRelatedField( - source="task__project", model=Project, read_only=True + model=Project, read_only=True ) included_serializers = {"project": "timed.projects.serializers.ProjectSerializer"} @@ -49,9 +49,9 @@ class Meta: class TaskStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField(read_only=True) - task = relations.ResourceRelatedField(model=Task, read_only=True) + project = relations.ResourceRelatedField(model=Project, read_only=True) - included_serializers = {"task": "timed.projects.serializers.TaskSerializer"} + included_serializers = {"project": "timed.projects.serializers.ProjectSerializer"} class Meta: resource_name = "task-statistics" diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index a271c7820..9ca406ce4 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -7,6 +7,7 @@ from timed.conftest import setup_customer_and_employment_status from timed.employment.factories import EmploymentFactory from timed.tracking.factories import ReportFactory +from timed.projects.models import Customer @pytest.mark.parametrize( @@ -56,12 +57,7 @@ def test_customer_statistic_list( "id": str(report.task.project.customer.id), "attributes": {"duration": "03:00:00"}, "relationships": { - "customer": { - "data": { - "id": str(report.task.project.customer.id), - "type": "customers", - } - } + "customer": {"data": {"id": str(report.task.project.customer.id), "type": "customers"}} }, }, { @@ -69,17 +65,11 @@ def test_customer_statistic_list( "id": str(report2.task.project.customer.id), "attributes": {"duration": "04:00:00"}, "relationships": { - "customer": { - "data": { - "id": str(report2.task.project.customer.id), - "type": "customers", - } - } + "customer": {"data": {"id": str(report2.task.project.customer.id), "type": "customers"}} }, }, ] assert json["data"] == expected_data - assert len(json["included"]) == 2 assert json["meta"]["total-time"] == "07:00:00" @@ -100,7 +90,7 @@ def test_customer_statistic_detail( url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) with django_assert_num_queries(expected): result = auth_client.get( - url, data={"ordering": "duration", "include": "customer"} + url, data={"ordering": "duration"} ) assert result.status_code == status_code if status_code == status.HTTP_200_OK: diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index f709dee14..b63579112 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -42,7 +42,7 @@ def test_project_statistic_list( url = reverse("project-statistic-list") with django_assert_num_queries(expected): result = auth_client.get( - url, data={"ordering": "duration", "include": "project,project.customer"} + url, data={"ordering": "duration", "include": "project"} ) assert result.status_code == status_code @@ -71,5 +71,5 @@ def test_project_statistic_list( }, ] assert json["data"] == expected_json - assert len(json["included"]) == 4 + assert len(json["included"]) == 2 assert json["meta"]["total-time"] == "07:00:00" diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index fed203dcd..cb9bcef79 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -47,8 +47,8 @@ def test_task_statistic_list( result = auth_client.get( url, data={ - "ordering": "task__name", - "include": "task,task.project,task.project.customer", + "ordering": "name", + "include": "project,project.customer", }, ) assert result.status_code == status_code @@ -61,7 +61,7 @@ def test_task_statistic_list( "id": str(task_test.id), "attributes": {"duration": "03:00:00"}, "relationships": { - "task": {"data": {"id": str(task_test.id), "type": "tasks"}} + "project": {"data": {"id": str(task_test.project.id), "type": "projects"}} }, }, { @@ -69,10 +69,10 @@ def test_task_statistic_list( "id": str(task_z.id), "attributes": {"duration": "02:00:00"}, "relationships": { - "task": {"data": {"id": str(task_z.id), "type": "tasks"}} + "project": {"data": {"id": str(task_z.project.id), "type": "projects"}} }, }, ] assert json["data"] == expected_json - assert len(json["included"]) == 6 + assert len(json["included"]) == 4 assert json["meta"]["total-time"] == "05:00:00" diff --git a/timed/reports/views.py b/timed/reports/views.py index e6968d273..059623978 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -15,7 +15,9 @@ from timed.mixins import AggregateQuerysetMixin from timed.permissions import IsAuthenticated, IsInternal, IsSuperUser +from timed.projects.models import Customer, Project, Task from timed.reports import serializers +from timed.reports.filters import TaskStatisticFilterSet from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report from timed.tracking.views import ReportViewSet @@ -72,9 +74,9 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Customer statistics calculates total reported time per customer.""" serializer_class = serializers.CustomerStatisticSerializer - filterset_class = ReportFilterSet - ordering_fields = ("task__project__customer__name", "duration") - ordering = ("task__project__customer__name",) + filterset_class = TaskStatisticFilterSet + ordering_fields = ("project__customer__name", "duration") + ordering = ("project__customer__name",) permission_classes = [ # internal employees or super users may read all customer statistics (IsInternal | IsSuperUser) @@ -82,10 +84,12 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = Report.objects.all() - queryset = queryset.values("task__project__customer") - queryset = queryset.annotate(duration=Sum("duration")) - queryset = queryset.annotate(pk=F("task__project__customer")) + queryset = Task.objects.all() + queryset = queryset.values("project__customer") + queryset = queryset.annotate( + duration=Sum("reports__duration"), + pk=F("project__customer"), + ) return queryset @@ -94,9 +98,9 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Project statistics calculates total reported time per project.""" serializer_class = serializers.ProjectStatisticSerializer - filterset_class = ReportFilterSet - ordering_fields = ("task__project__name", "duration") - ordering = ("task__project__name",) + filterset_class = TaskStatisticFilterSet + ordering_fields = ("project__name", "duration") + ordering = ("project__name",) permission_classes = [ # internal employees or super users may read all customer statistics (IsInternal | IsSuperUser) @@ -104,12 +108,12 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = Report.objects.all() - queryset = queryset.values("task__project") - queryset = queryset.annotate(duration=Sum("duration")) + queryset = Task.objects.all() + queryset = queryset.values("project") queryset = queryset.annotate( - pk=F("task__project"), - sum_remaining=F("task__project__total_remaining_effort"), + duration=Sum("reports__duration"), + pk=F("project"), + sum_remaining_effort=F("project__total_remaining_effort") ) return queryset @@ -119,9 +123,9 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Task statistics calculates total reported time per task.""" serializer_class = serializers.TaskStatisticSerializer - filterset_class = ReportFilterSet - ordering_fields = ("task__name", "duration") - ordering = ("task__name",) + filterset_class = TaskStatisticFilterSet + ordering_fields = ("name", "duration") + ordering = ("name",) permission_classes = [ # internal employees or super users may read all customer statistics (IsInternal | IsSuperUser) @@ -129,12 +133,13 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = Report.objects.all() - queryset = queryset.values("task") - queryset = queryset.annotate(duration=Sum("duration")) + queryset = Task.objects.all() + queryset = queryset.values("id") queryset = queryset.annotate( - pk=F("task"), - most_recent_remaining_effort=F("task__most_recent_remaining_effort"), + duration=Sum("reports__duration"), + pk=F("id"), + project=F("project"), + most_recent_remaining_effort=F("most_recent_remaining_effort") ) return queryset From b5b9c8d633a4d6fc8633594349feec7ee59fb8d0 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 2 Sep 2022 11:56:34 +0200 Subject: [PATCH 909/980] feat(filters): allow filtering of tasks and reports in statistics When filtering statistics, we face the problem that we want to have a list of all tasks, and count the reported hours. Due to the way Django works, the Sum of reported hours would trigger an (unfiltered) subquery or Join, so our filters don't apply. Therefore, we now have a "split" pseudo-queryset, which contains separate querysets for tasks and reports. It then applies the filters to EITHER of the querysets as needed (depending on prefix), then combines them at the end of the filtering phase, using a subquery. --- timed/reports/filters.py | 10 +- timed/reports/serializers.py | 19 +-- .../reports/tests/test_customer_statistic.py | 26 ++-- timed/reports/tests/test_project_statistic.py | 36 +++--- timed/reports/tests/test_task_statistic.py | 67 +++++++++- timed/reports/views.py | 120 +++++++++++++++--- 6 files changed, 207 insertions(+), 71 deletions(-) diff --git a/timed/reports/filters.py b/timed/reports/filters.py index 4ec31c704..0e6b5b592 100644 --- a/timed/reports/filters.py +++ b/timed/reports/filters.py @@ -5,7 +5,14 @@ from timed.projects.models import Task -class TaskStatisticFilterSet(FilterSet): +class MultiQSFilterMixin(): + def filter_queryset(self, queryset): + qs = super().filter_queryset(queryset) + return qs._finalize() + + + +class TaskStatisticFilterSet(MultiQSFilterMixin, FilterSet): """Filter set for the customer, project and task statistic endpoint.""" id = BaseInFilter() @@ -141,6 +148,7 @@ def filter_cost_center(self, queryset, name, value): | Q(project__cost_center=value) & Q(cost_center__isnull=True) ) + class Meta: """Meta information for the task statistic filter set.""" diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 8908c98c8..85169b920 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -1,6 +1,11 @@ from django.contrib.auth import get_user_model from rest_framework_json_api import relations -from rest_framework_json_api.serializers import DurationField, IntegerField, Serializer +from rest_framework_json_api.serializers import ( + CharField, + DurationField, + IntegerField, + Serializer, +) from timed.projects.models import Customer, Project, Task from timed.serializers import TotalTimeRootMetaMixin @@ -25,9 +30,7 @@ class Meta: class CustomerStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() - customer = relations.ResourceRelatedField( - source="project__customer", model=Customer, read_only=True - ) + name = CharField(read_only=True) included_serializers = {"customer": "timed.projects.serializers.CustomerSerializer"} @@ -37,17 +40,15 @@ class Meta: class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() - project = relations.ResourceRelatedField( - model=Project, read_only=True - ) - - included_serializers = {"project": "timed.projects.serializers.ProjectSerializer"} + name = CharField() class Meta: resource_name = "project-statistics" class TaskStatisticSerializer(TotalTimeRootMetaMixin, Serializer): + name = CharField(read_only=True) + most_recent_remaining_effort = DurationField(read_only=True) duration = DurationField(read_only=True) project = relations.ResourceRelatedField(model=Project, read_only=True) diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 9ca406ce4..bc5d7839d 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -6,8 +6,8 @@ from timed.conftest import setup_customer_and_employment_status from timed.employment.factories import EmploymentFactory -from timed.tracking.factories import ReportFactory from timed.projects.models import Customer +from timed.tracking.factories import ReportFactory @pytest.mark.parametrize( @@ -15,9 +15,9 @@ [ (False, True, False, 1, status.HTTP_403_FORBIDDEN), (False, True, True, 1, status.HTTP_403_FORBIDDEN), - (True, False, False, 4, status.HTTP_200_OK), - (True, True, False, 4, status.HTTP_200_OK), - (True, True, True, 4, status.HTTP_200_OK), + (True, False, False, 3, status.HTTP_200_OK), + (True, True, False, 3, status.HTTP_200_OK), + (True, True, True, 3, status.HTTP_200_OK), ], ) def test_customer_statistic_list( @@ -55,17 +55,17 @@ def test_customer_statistic_list( { "type": "customer-statistics", "id": str(report.task.project.customer.id), - "attributes": {"duration": "03:00:00"}, - "relationships": { - "customer": {"data": {"id": str(report.task.project.customer.id), "type": "customers"}} + "attributes": { + "duration": "03:00:00", + "name": report.task.project.customer.name, }, }, { "type": "customer-statistics", "id": str(report2.task.project.customer.id), - "attributes": {"duration": "04:00:00"}, - "relationships": { - "customer": {"data": {"id": str(report2.task.project.customer.id), "type": "customers"}} + "attributes": { + "duration": "04:00:00", + "name": report2.task.project.customer.name, }, }, ] @@ -76,7 +76,7 @@ def test_customer_statistic_list( @pytest.mark.parametrize( "is_employed, expected, status_code", [ - (True, 5, status.HTTP_200_OK), + (True, 4, status.HTTP_200_OK), (False, 1, status.HTTP_403_FORBIDDEN), ], ) @@ -89,9 +89,7 @@ def test_customer_statistic_detail( url = reverse("customer-statistic-detail", args=[report.task.project.customer.id]) with django_assert_num_queries(expected): - result = auth_client.get( - url, data={"ordering": "duration"} - ) + result = auth_client.get(url, data={"ordering": "duration"}) assert result.status_code == status_code if status_code == status.HTTP_200_OK: json = result.json() diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index b63579112..5f4616c0b 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -5,6 +5,7 @@ from rest_framework import status from timed.conftest import setup_customer_and_employment_status +from timed.projects.factories import TaskFactory from timed.tracking.factories import ReportFactory @@ -13,9 +14,9 @@ [ (False, True, False, 1, status.HTTP_403_FORBIDDEN), (False, True, True, 1, status.HTTP_403_FORBIDDEN), - (True, False, False, 4, status.HTTP_200_OK), - (True, True, False, 4, status.HTTP_200_OK), - (True, True, True, 4, status.HTTP_200_OK), + (True, False, False, 3, status.HTTP_200_OK), + (True, True, False, 3, status.HTTP_200_OK), + (True, True, True, 3, status.HTTP_200_OK), ], ) def test_project_statistic_list( @@ -38,12 +39,12 @@ def test_project_statistic_list( report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) + task = TaskFactory(project=report.task.project) + ReportFactory.create(duration=timedelta(hours=2), task=task) url = reverse("project-statistic-list") with django_assert_num_queries(expected): - result = auth_client.get( - url, data={"ordering": "duration", "include": "project"} - ) + result = auth_client.get(url, data={"ordering": "duration"}) assert result.status_code == status_code if status_code == status.HTTP_200_OK: @@ -51,25 +52,20 @@ def test_project_statistic_list( expected_json = [ { "type": "project-statistics", - "id": str(report.task.project.id), - "attributes": {"duration": "03:00:00"}, - "relationships": { - "project": { - "data": {"id": str(report.task.project.id), "type": "projects"} - } + "id": str(report2.task.project.id), + "attributes": { + "duration": "04:00:00", + "name": report2.task.project.name, }, }, { "type": "project-statistics", - "id": str(report2.task.project.id), - "attributes": {"duration": "04:00:00"}, - "relationships": { - "project": { - "data": {"id": str(report2.task.project.id), "type": "projects"} - } + "id": str(report.task.project.id), + "attributes": { + "duration": "05:00:00", + "name": report.task.project.name, }, }, ] assert json["data"] == expected_json - assert len(json["included"]) == 2 - assert json["meta"]["total-time"] == "07:00:00" + assert json["meta"]["total-time"] == "09:00:00" diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index cb9bcef79..e0bdd1854 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import date, timedelta import pytest from django.urls import reverse @@ -59,20 +59,75 @@ def test_task_statistic_list( { "type": "task-statistics", "id": str(task_test.id), - "attributes": {"duration": "03:00:00"}, + "attributes": { + "duration": "03:00:00", + "name": str(task_test.name), + "most-recent-remaining-effort": None, + }, "relationships": { - "project": {"data": {"id": str(task_test.project.id), "type": "projects"}} + "project": { + "data": {"id": str(task_test.project.id), "type": "projects"} + } }, }, { "type": "task-statistics", "id": str(task_z.id), - "attributes": {"duration": "02:00:00"}, + "attributes": { + "duration": "02:00:00", + "name": str(task_z.name), + "most-recent-remaining-effort": None, + }, "relationships": { - "project": {"data": {"id": str(task_z.project.id), "type": "projects"}} + "project": { + "data": {"id": str(task_z.project.id), "type": "projects"} + } }, }, ] assert json["data"] == expected_json - assert len(json["included"]) == 4 assert json["meta"]["total-time"] == "05:00:00" + + +@pytest.mark.parametrize( + "filter, expected_result", + [("from_date", 5), ("customer", 3)], +) +def test_task_statistic_filtered( + auth_client, + filter, + expected_result, +): + + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=True, + is_customer=True, + is_employed=True, + is_external=False, + ) + + task_z = TaskFactory.create(name="Z") + task_test = TaskFactory.create(name="Test") + + ReportFactory.create(duration=timedelta(hours=1), date="2022-08-05", task=task_test) + ReportFactory.create(duration=timedelta(hours=2), date="2022-08-30", task=task_test) + ReportFactory.create(duration=timedelta(hours=3), date="2022-09-01", task=task_z) + + filter_values = { + "from_date": "2022-08-20", # last two reports + "customer": str(task_test.project.customer.pk), # first two + } + the_filter = {filter: filter_values[filter]} + + url = reverse("task-statistic-list") + result = auth_client.get( + url, + data={"ordering": "name", "include": "project,project.customer", **the_filter}, + ) + assert result.status_code == status.HTTP_200_OK + + json = result.json() + + assert json["meta"]["total-time"] == f"{expected_result:02}:00:00" diff --git a/timed/reports/views.py b/timed/reports/views.py index 059623978..ddb3cf097 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -5,7 +5,7 @@ from zipfile import ZipFile from django.conf import settings -from django.db.models import F, Sum +from django.db.models import DurationField, F, OuterRef, QuerySet, Subquery, Sum from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponse from ezodf import Cell, opendoc @@ -84,13 +84,15 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = Task.objects.all() - queryset = queryset.values("project__customer") - queryset = queryset.annotate( - duration=Sum("reports__duration"), - pk=F("project__customer"), + queryset = MultiQS( + start=Customer, + # Task.objects.all(), + # Report.objects.all(), + annotations={ + "customer_id": F("pk"), + "name": F("name"), + }, ) - return queryset @@ -108,14 +110,12 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = Task.objects.all() - queryset = queryset.values("project") - queryset = queryset.annotate( - duration=Sum("reports__duration"), - pk=F("project"), - sum_remaining_effort=F("project__total_remaining_effort") + queryset = MultiQS( + start=Project, + annotations={ + "name": F("name"), + }, ) - return queryset @@ -133,15 +133,93 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = Task.objects.all() - queryset = queryset.values("id") - queryset = queryset.annotate( - duration=Sum("reports__duration"), - pk=F("id"), - project=F("project"), - most_recent_remaining_effort=F("most_recent_remaining_effort") + queryset = MultiQS( + start=Task, + annotations={ + "name": F("name"), + "project": F("project"), + "most_recent_remaining_effort": F("most_recent_remaining_effort"), + }, ) + return queryset + + +class MultiQS(QuerySet): + def __init__(self, start, annotations): + self.model = Task # just to make filterset happy + + self._start = start + self._tasks = Task.objects.all() + self._reports = Report.objects.all() + + self._annotations = annotations + + def _clone(self): + new_self = MultiQS( + start=self._start, + annotations=self._annotations, + ) + new_self._tasks = self._tasks + new_self._reports = self._reports + + return new_self + + def _apply(self, method, *args, **kwargs): + assert not args, "Q object filtering currently not supported" + + new_self = self._clone() + task_filters = {} + report_filters = {} + for kw, val in kwargs.items(): + if kw.startswith("reports__"): + kw = kw.replace("reports__", "") + report_filters[kw] = val + else: + task_filters[kw] = val + + if report_filters: + new_self._reports = getattr(new_self._reports, method)(**report_filters) + if task_filters: + new_self._tasks = getattr(new_self._tasks, method)(**task_filters) + return new_self + + def filter(self, *args, **kwargs): + return self._apply("filter", *args, **kwargs) + + def exclude(self, *args, **kwargs): # pragma: no cover + return self._apply("exclude", *args, **kwargs) + + def all(self): + return self._clone() + + def _finalize(self): + task_lookup = { + "Customer": "projects__tasks__id__in", + "Project": "tasks__id__in", + "Task": "pk__in", + } + base_qs = self._start.objects.all().values("pk") + task_lookup = task_lookup[self._start.__name__] + + back_ref_lookups = { + "Customer": "task__project__customer_id", + "Project": "task__project_id", + "Task": "task_id", + } + back_ref = back_ref_lookups[self._start.__name__] + + base_qs = base_qs.filter(**{task_lookup: self._tasks.values("pk")}) + + report_qs = ( + self._reports.values(back_ref) + .annotate(report_duration=Sum("duration", output_field=DurationField())) + .filter(**{back_ref: OuterRef("pk")}) + .values("report_duration") + ) + queryset = base_qs.annotate( + duration=Subquery(report_qs), **self._annotations + ).distinct() return queryset From b629c9d97cec7d4779baaa94f6eb628b394a3c53 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 8 Sep 2022 11:23:45 +0200 Subject: [PATCH 910/980] fix(filters): allow Q filtering for MultiQS querysets --- timed/reports/filters.py | 100 ++---------------- timed/reports/serializers.py | 2 +- .../reports/tests/test_customer_statistic.py | 1 - timed/reports/tests/test_task_statistic.py | 13 ++- timed/reports/views.py | 14 ++- 5 files changed, 34 insertions(+), 96 deletions(-) diff --git a/timed/reports/filters.py b/timed/reports/filters.py index 0e6b5b592..f237f5677 100644 --- a/timed/reports/filters.py +++ b/timed/reports/filters.py @@ -1,17 +1,20 @@ from django.db.models import Q -from django_filters.rest_framework import DateFilter, FilterSet, NumberFilter, BaseInFilter +from django_filters.rest_framework import ( + BaseInFilter, + DateFilter, + FilterSet, + NumberFilter, +) -from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee -from timed.projects.models import Task +from timed.projects.models import CustomerAssignee, ProjectAssignee, Task, TaskAssignee -class MultiQSFilterMixin(): +class MultiQSFilterMixin: def filter_queryset(self, queryset): qs = super().filter_queryset(queryset) return qs._finalize() - class TaskStatisticFilterSet(MultiQSFilterMixin, FilterSet): """Filter set for the customer, project and task statistic endpoint.""" @@ -34,108 +37,28 @@ class TaskStatisticFilterSet(MultiQSFilterMixin, FilterSet): cost_center = NumberFilter(method="filter_cost_center") rejected = NumberFilter(field_name="reports__rejected") - def filter_has_reviewer(self, queryset, name, value): if not value: # pragma: no cover return queryset - # reports in which user is customer assignee and responsible reviewer - reports_customer_assignee_is_reviewer = queryset.filter( + return queryset.filter( Q( project__customer_id__in=CustomerAssignee.objects.filter( is_reviewer=True, user_id=value ).values("customer_id") ) - ).exclude( - Q( - project_id__in=ProjectAssignee.objects.filter( - is_reviewer=True - ).values("project_id") - ) | Q( - id__in=TaskAssignee.objects.filter(is_reviewer=True).values( - "task_id" - ) - ) - ) - - # reports in which user is project assignee and responsible reviewer - reports_project_assignee_is_reviewer = queryset.filter( - Q( project_id__in=ProjectAssignee.objects.filter( is_reviewer=True, user_id=value ).values("project_id") ) - ).exclude( - Q( - id__in=TaskAssignee.objects.filter(is_reviewer=True).values( - "task_id" - ) - ) - ) - - # reports in which user task assignee and responsible reviewer - reports_task_assignee_is_reviewer = queryset.filter( - Q( + | Q( id__in=TaskAssignee.objects.filter( is_reviewer=True, user_id=value ).values("task_id") ) ) - return ( - reports_customer_assignee_is_reviewer - | reports_project_assignee_is_reviewer - | reports_task_assignee_is_reviewer - ) - - def filter_editable(self, queryset, name, value): - """Filter reports whether they are editable by current user. - - When set to `1` filter all results to what is editable by current - user. If set to `0` to not editable. - """ - user = self.request.user - assignee_filter = ( - # avoid duplicates by using subqueries instead of joins - Q(reports__user__in=user.supervisees.values("id")) - | Q( - task_assignees__user=user, - task_assignees__is_reviewer=True, - ) - | Q( - project__project_assignees__user=user, - project__project_assignees__is_reviewer=True, - ) - | Q( - project__customer__customer_assignees__user=user, - project__customer__customer_assignees__is_reviewer=True, - ) - | Q(reports__user=user) - ) - unfinished_filter = Q(reports__verified_by__isnull=True) - editable_filter = assignee_filter & unfinished_filter - - if value: # editable - if user.is_superuser: - # superuser may edit all reports - return queryset - elif user.is_accountant: - return queryset.filter(unfinished_filter) - # only owner, reviewer or supervisor may change unverified reports - queryset = queryset.filter(editable_filter).distinct() - - return queryset - else: # not editable - if user.is_superuser: - # no reports which are not editable - return queryset.none() - elif user.is_accountant: - return queryset.exclude(unfinished_filter) - - queryset = queryset.exclude(editable_filter) - return queryset - def filter_cost_center(self, queryset, name, value): """ Filter report by cost center. @@ -148,9 +71,8 @@ def filter_cost_center(self, queryset, name, value): | Q(project__cost_center=value) & Q(cost_center__isnull=True) ) - class Meta: """Meta information for the task statistic filter set.""" model = Task - fields = ["most_recent_remaining_effort"] \ No newline at end of file + fields = ["most_recent_remaining_effort"] diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 85169b920..3088d30c8 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -7,7 +7,7 @@ Serializer, ) -from timed.projects.models import Customer, Project, Task +from timed.projects.models import Project from timed.serializers import TotalTimeRootMetaMixin diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index bc5d7839d..768465100 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -6,7 +6,6 @@ from timed.conftest import setup_customer_and_employment_status from timed.employment.factories import EmploymentFactory -from timed.projects.models import Customer from timed.tracking.factories import ReportFactory diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index e0bdd1854..50255cb57 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -1,11 +1,12 @@ -from datetime import date, timedelta +from datetime import timedelta import pytest from django.urls import reverse from rest_framework import status from timed.conftest import setup_customer_and_employment_status -from timed.projects.factories import TaskFactory +from timed.employment.factories import UserFactory +from timed.projects.factories import CostCenterFactory, TaskAssigneeFactory, TaskFactory from timed.tracking.factories import ReportFactory @@ -91,7 +92,7 @@ def test_task_statistic_list( @pytest.mark.parametrize( "filter, expected_result", - [("from_date", 5), ("customer", 3)], + [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], ) def test_task_statistic_filtered( auth_client, @@ -108,8 +109,10 @@ def test_task_statistic_filtered( is_external=False, ) - task_z = TaskFactory.create(name="Z") + cost_center = CostCenterFactory() + task_z = TaskFactory.create(name="Z", cost_center=cost_center) task_test = TaskFactory.create(name="Test") + reviewer = TaskAssigneeFactory(user=UserFactory(), task=task_test, is_reviewer=True) ReportFactory.create(duration=timedelta(hours=1), date="2022-08-05", task=task_test) ReportFactory.create(duration=timedelta(hours=2), date="2022-08-30", task=task_test) @@ -118,6 +121,8 @@ def test_task_statistic_filtered( filter_values = { "from_date": "2022-08-20", # last two reports "customer": str(task_test.project.customer.pk), # first two + "cost_center": str(cost_center.pk), # first two + "reviewer": str(reviewer.user.pk), # first two } the_filter = {filter: filter_values[filter]} diff --git a/timed/reports/views.py b/timed/reports/views.py index ddb3cf097..0fe64ce2b 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -165,8 +165,18 @@ def _clone(self): return new_self + def _validate_q(self, q_object): + if isinstance(q_object, tuple): + assert not q_object[0].startswith( + "reports__" + ), "Filtering of reports not possible" + else: + for child in q_object.children: + self._validate_q(child) + def _apply(self, method, *args, **kwargs): - assert not args, "Q object filtering currently not supported" + for arg in args: + self._validate_q(arg) new_self = self._clone() task_filters = {} @@ -182,6 +192,8 @@ def _apply(self, method, *args, **kwargs): new_self._reports = getattr(new_self._reports, method)(**report_filters) if task_filters: new_self._tasks = getattr(new_self._tasks, method)(**task_filters) + if args: + new_self._tasks = getattr(new_self._tasks, method)(*args) return new_self def filter(self, *args, **kwargs): From 0af5f5460b58a3da5a2590f8504632a9bf5673fa Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 16 Sep 2022 10:41:18 +0200 Subject: [PATCH 911/980] chore: remove unnecessary comment --- timed/reports/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index 0fe64ce2b..1a555acd8 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -86,8 +86,6 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): def get_queryset(self): queryset = MultiQS( start=Customer, - # Task.objects.all(), - # Report.objects.all(), annotations={ "customer_id": F("pk"), "name": F("name"), From 5e3ae64ca217717e6a17c1b29d0bde8a8b53bd08 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 25 Oct 2022 10:51:08 +0200 Subject: [PATCH 912/980] chore(deps): use poetry for dependency and tool management --- .dockerignore | 2 + .flake8 | 33 + .github/workflows/test.yml | 15 +- CONTRIBUTING.md | 8 +- Dockerfile | 9 +- Makefile | 6 +- README.md | 2 +- cmd.sh | 6 +- docker-compose.override.yml | 4 +- poetry.lock | 2277 +++++++++++++++++++++++++++++++++++ pyproject.toml | 130 ++ requirements-dev.txt | 25 - requirements.txt | 25 - setup.cfg | 78 -- setup.py | 75 -- 15 files changed, 2470 insertions(+), 225 deletions(-) create mode 100644 .flake8 create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.dockerignore b/.dockerignore index e1fb0133d..019c069c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ Dockerfile .dockerignore .env .git +.github *.pyc __pycache__ *.pyd @@ -14,3 +15,4 @@ __pycache__ .Python .python-version *.swp +.venv diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..cc912a7ab --- /dev/null +++ b/.flake8 @@ -0,0 +1,33 @@ +[flake8] +ignore = + # whitespace before ':' + E203, + # too many leading ### in a block comment + E266, + # line too long (managed by black) + E501, + # Line break occurred before a binary operator (this is not PEP8 compatible) + W503, + # Missing docstring in public module + D100, + # Missing docstring in public class + D101, + # Missing docstring in public method + D102, + # Missing docstring in public function + D103, + # Missing docstring in public package + D104, + # Missing docstring in magic method + D105, + # Missing docstring in public package + D106, + # Missing docstring in __init__ + D107, + # needed because of https://github.com/ambv/black/issues/144 + D202, + # other string does contain unindexed parameters + P103 +max-line-length = 80 +exclude = migrations snapshots +max-complexity = 10 \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index acfe4786b..6938ae01e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,15 +11,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - uses: actions/cache@v3 + with: + path: .venv + key: poetry-${{ hashFiles('poetry.lock')}} + restore-keys: | + peotry- - name: Build the project run: | echo "ENV=dev" > .env docker-compose up -d --build backend - docker-compose exec -T backend pip install -r requirements-dev.txt - name: Lint the code run: | - docker-compose exec -T backend black --check . - docker-compose exec -T backend flake8 - docker-compose exec -T backend ./manage.py makemigrations --check --dry-run --no-input + docker-compose exec -T backend poetry run black --check . + docker-compose exec -T backend poetry run flake8 + docker-compose exec -T backend poetry run python manage.py makemigrations --check --dry-run --no-input - name: Run pytest - run: docker-compose exec -T backend pytest --no-cov-on-fail --cov --create-db -vv + run: docker-compose exec -T backend poetry run pytest --no-cov-on-fail --cov --create-db -vv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f225aa913..690dd5a9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,13 +32,13 @@ etc. ```bash # linting -flake8 +poetry run flake8 # format code -black . +poetry run black . # running tests -pytest +poetry run pytest # create migrations -./manage.py makemigrations +poetry run python manage.py makemigrations ``` Writing of code can still happen outside the docker container of course. diff --git a/Dockerfile b/Dockerfile index b91f1d317..a7003897c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /app -ARG REQUIREMENTS=requirements.txt - ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static ENV WAITFORIT_TIMEOUT 0 @@ -21,8 +19,11 @@ ENV UWSGI_MAX_REQUESTS 2000 ENV UWSGI_HARAKIRI 5 ENV UWSGI_PROCESSES 4 -COPY requirements.txt requirements-dev.txt /app/ -RUN pip install --upgrade --no-cache-dir --requirement $REQUIREMENTS --disable-pip-version-check +RUN pip install -U poetry + +ARG INSTALL_DEV_DEPENDENCIES=false +COPY pyproject.toml poetry.lock /app/ +RUN if [ "$INSTALL_DEV_DEPENDENCIES" = "true" ]; then poetry install; else poetry install --no-dev; fi COPY . /app diff --git a/Makefile b/Makefile index 139abb94d..9fbcb5c0c 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ stop: ## Stop the development server @docker-compose stop test: ## Test the project - @docker-compose exec backend sh -c "black --check . && flake8 && pytest --no-cov-on-fail --cov" + @docker-compose exec backend sh -c "poetry run black --check . && poetry run flake8 && poetry run pytest --no-cov-on-fail --cov" shell: ## Shell into the backend @docker-compose exec backend bash @@ -20,7 +20,7 @@ dbshell: ## Start a psql shell @docker-compose exec db psql -Utimed timed flush: ## Flush database contents - @docker-compose exec backend ./manage.py flush --no-input + @docker-compose exec backend poetry run python manage.py flush --no-input loaddata: flush ## Loads test data into the database - @docker-compose exec backend ./manage.py loaddata timed/fixtures/test_data.json + @docker-compose exec backend poetry run python manage.py loaddata timed/fixtures/test_data.json diff --git a/README.md b/README.md index f1d2a8b2a..afe9263bf 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ To access the Django admin interface you will have to change the admin password ```console $ make shell -root@0a036a10f3c4:/app# python manage.py changepassword admin +root@0a036a10f3c4:/app# peotry run python manage.py changepassword admin Changing password for user 'admin' Password: Password (again): diff --git a/cmd.sh b/cmd.sh index bbcee6c87..e670a0609 100755 --- a/cmd.sh +++ b/cmd.sh @@ -2,8 +2,8 @@ GUNICORN_WORKERS="${GUNICORN_WORKERS:-8}" -./manage.py collectstatic --noinput +poetry run python manage.py collectstatic --noinput wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- \ - ./manage.py migrate --no-input && \ - gunicorn --workers=$GUNICORN_WORKERS --bind=0.0.0.0:80 timed.wsgi:application + poetry run python manage.py migrate --no-input && \ + poetry run gunicorn --workers=$GUNICORN_WORKERS --bind=0.0.0.0:80 timed.wsgi:application diff --git a/docker-compose.override.yml b/docker-compose.override.yml index aa0e277c5..59718ff9d 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -5,7 +5,7 @@ services: build: context: . args: - REQUIREMENTS: requirements-dev.txt + INSTALL_DEV_DEPENDENCIES: "true" depends_on: - mailhog environment: @@ -14,7 +14,7 @@ services: - DJANGO_OIDC_USERNAME_CLAIM=preferred_username volumes: - ./:/app - command: /bin/sh -c "wait-for-it.sh -t 60 db:5432 -- ./manage.py migrate && ./manage.py runserver 0.0.0.0:80" + command: /bin/sh -c "wait-for-it.sh -t 60 db:5432 -- poetry run python manage.py migrate && poetry run python manage.py runserver 0.0.0.0:80" networks: - timed.local diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..14d09f856 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,2277 @@ +[[package]] +name = "appnope" +version = "0.1.3" +description = "Disable App Nap on macOS >= 10.9" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "asgiref" +version = "3.5.2" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "asttokens" +version = "2.0.8" +description = "Annotate AST trees with source code positions" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +six = "*" + +[package.extras] +test = ["astroid (<=2.5.3)", "pytest"] + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] + +[[package]] +name = "babel" +version = "2.10.3" +description = "Internationalization utilities" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "black" +version = "22.3.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2022.9.24" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "chardet" +version = "5.0.0" +description = "Universal encoding detector for Python 3" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode-backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "coverage" +version = "6.4.1" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cryptography" +version = "38.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "django" +version = "3.2.16" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +asgiref = ">=3.3.2,<4" +pytz = "*" +sqlparse = ">=0.2.2" + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-cors-headers" +version = "3.13.0" +description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +Django = ">=3.2" + +[[package]] +name = "django-environ" +version = "0.8.1" +description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." +category = "main" +optional = false +python-versions = ">=3.4,<4" + +[package.extras] +develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] +docs = ["furo (>=2021.8.17b43,<2021.9.0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] +testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] + +[[package]] +name = "django-excel" +version = "0.0.10" +description = "A django middleware that provides one application programminginterface to read and write data in different excel file formats" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +Django = ">=1.6.1" +pyexcel = ">=0.5.7" +pyexcel-webio = ">=0.1.2" + +[package.extras] +ods = ["pyexcel-ods3 (>=0.4.0)"] +xls = ["pyexcel-xls (>=0.4.0)"] +xlsx = ["pyexcel-xlsx (>=0.4.0)"] + +[[package]] +name = "django-filter" +version = "21.1" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Django = ">=2.2" + +[[package]] +name = "django-money" +version = "2.1.1" +description = "Adds support for using money and currency fields in django models and forms. Uses py-moneyed as the money implementation." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Django = ">=2.2" +py-moneyed = ">=1.2,<2.0" +setuptools = "*" + +[package.extras] +exchange = ["certifi"] +test = ["mixer", "pytest (>=3.1.0)", "pytest-cov", "pytest-django", "pytest-pythonpath"] + +[[package]] +name = "django-multiselectfield" +version = "0.1.12" +description = "Django multiple select field" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +django = ">=1.4" + +[[package]] +name = "django-nested-inline" +version = "0.4.5" +description = "Recursive nesting of inline forms for Django Admin" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "django-prometheus" +version = "2.2.0" +description = "Django middlewares to monitor your application with Prometheus.io." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +prometheus-client = ">=0.7" + +[[package]] +name = "djangorestframework" +version = "3.13.1" +description = "Web APIs for Django, made easy." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = ">=2.2" +pytz = "*" + +[[package]] +name = "djangorestframework-jsonapi" +version = "5.0.0" +description = "A Django REST framework API adapter for the JSON:API spec." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +django = ">=2.2,<4.1" +djangorestframework = ">=3.12,<3.14" +inflection = ">=0.5.0" + +[package.extras] +django-filter = ["django-filter (>=2.4)"] +django-polymorphic = ["django-polymorphic (>=3.0)"] +openapi = ["pyyaml (>=5.4)", "uritemplate (>=3.0.1)"] + +[[package]] +name = "et-xmlfile" +version = "1.1.0" +description = "An implementation of lxml.xmlfile for the standard library" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "executing" +version = "1.1.1" +description = "Get the currently executing AST node of a frame, and other information" +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +tests = ["asttokens", "littleutils", "pytest", "rich"] + +[[package]] +name = "factory-boy" +version = "3.2.1" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Faker = ">=0.7.0" + +[package.extras] +dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] +doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] + +[[package]] +name = "faker" +version = "15.1.1" +description = "Faker is a Python package that generates fake data for you." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +python-dateutil = ">=2.4" + +[[package]] +name = "fancycompleter" +version = "0.9.1" +description = "colorful TAB completion for Python prompt" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pyreadline = {version = "*", markers = "platform_system == \"Windows\""} +pyrepl = ">=0.8.2" + +[[package]] +name = "fastdiff" +version = "0.3.0" +description = "A fast native implementation of diff algorithm with a pure python fallback" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +wasmer = ">=1.0.0" +wasmer-compiler-cranelift = ">=1.0.0" + +[[package]] +name = "flake8" +version = "4.0.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" + +[[package]] +name = "flake8-blind-except" +version = "0.2.1" +description = "A flake8 extension that checks for blind except: statements" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "flake8-debugger" +version = "4.1.2" +description = "ipdb/pdb statement checker plugin for flake8" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +flake8 = ">=3.0" +pycodestyle = "*" + +[[package]] +name = "flake8-deprecated" +version = "1.3" +description = "Warns about deprecated method calls." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3.0.0" + +[[package]] +name = "flake8-docstrings" +version = "1.6.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + +[[package]] +name = "flake8-isort" +version = "4.1.1" +description = "flake8 plugin that integrates isort ." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3.2.1,<5" +isort = ">=4.3.5,<6" +testfixtures = ">=6.8.0,<7" + +[package.extras] +test = ["pytest-cov"] + +[[package]] +name = "flake8-string-format" +version = "0.3.0" +description = "string format checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "freezegun" +version = "1.2.2" +description = "Let your Python tests travel through time" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +python-dateutil = ">=2.7" + +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +setuptools = ">=3.0" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "5.0.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "ipdb" +version = "0.13.9" +description = "IPython-enabled pdb" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +decorator = {version = "*", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""} +setuptools = "*" +toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} + +[[package]] +name = "ipython" +version = "8.5.0" +description = "IPython: Productive Interactive Computing" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">3.0.1,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" + +[package.extras] +all = ["Sphinx (>=1.3)", "black", "curio", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.19)", "pandas", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "testpath", "trio"] +black = ["black"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.19)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +colors = ["colorama (>=0.4.3,<0.5.0)"] +pipfile-deprecated-finder = ["pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "jedi" +version = "0.18.1" +description = "An autocompletion tool for Python that can be used for text editors." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +parso = ">=0.8.0,<0.9.0" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "josepy" +version = "1.13.0" +description = "JOSE protocol implementation in Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = ">=1.5" +PyOpenSSL = ">=0.13" +setuptools = ">=1.0" + +[package.extras] +dev = ["pytest", "tox"] +docs = ["Sphinx (>=1.0)", "sphinx-rtd-theme (>=1.0)"] +tests = ["coverage (>=4.0)", "flake8 (<4)", "isort", "mypy", "pytest (>=2.8.0)", "pytest-cov", "pytest-flake8 (>=0.5)", "types-pyOpenSSL", "types-pyRFC3339", "types-requests", "types-setuptools"] + +[[package]] +name = "lml" +version = "0.1.0" +description = "Load me later. A lazy plugin management system." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "lxml" +version = "4.9.1" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=0.29.7)"] + +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mozilla-django-oidc" +version = "2.0.0" +description = "A lightweight authentication and access management library for integration with OpenID Connect enabled authentication services." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +cryptography = "*" +Django = ">=2.2" +josepy = "*" +requests = "*" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "openpyxl" +version = "3.0.10" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +et-xmlfile = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + +[[package]] +name = "pathspec" +version = "0.10.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pdbpp" +version = "0.10.3" +description = "pdb++, a drop-in replacement for pdb" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +fancycompleter = ">=0.8" +pygments = "*" +wmctrl = "*" + +[package.extras] +funcsigs = ["funcsigs"] +testing = ["funcsigs", "pytest"] + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "prometheus-client" +version = "0.15.0" +description = "Python client for the Prometheus monitoring system." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +twisted = ["twisted"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.31" +description = "Library for building powerful interactive command lines in Python" +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "psycopg2-binary" +version = "2.9.5" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "py-moneyed" +version = "1.2" +description = "Provides Currency and Money classes for use in your Python code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +babel = ">=2.8.0" + +[package.extras] +tests = ["pytest (>=2.3.0)", "tox (>=1.6.0)"] + +[[package]] +name = "pycodestyle" +version = "2.8.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pydocstyle" +version = "6.1.1" +description = "Python docstring style checker" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +snowballstemmer = "*" + +[package.extras] +toml = ["toml"] + +[[package]] +name = "pyexcel" +version = "0.7.0" +description = "A wrapper library that provides one API to read, manipulate and writedata in different excel formats" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +chardet = "*" +lml = ">=0.0.4" +pyexcel-io = ">=0.6.2" +texttable = ">=0.8.2" + +[package.extras] +ods = ["pyexcel-ods3 (>=0.6.0)"] +xls = ["pyexcel-xls (>=0.6.0)"] +xlsx = ["pyexcel-xlsx (>=0.6.0)"] + +[[package]] +name = "pyexcel-ezodf" +version = "0.3.4" +description = "A Python package to create/manipulate OpenDocumentFormat files" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +lxml = "*" + +[[package]] +name = "pyexcel-io" +version = "0.6.6" +description = "A python library to read and write structured data in csv, zipped csvformat and to/from databases" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +lml = ">=0.0.4" + +[package.extras] +ods = ["pyexcel-ods3 (>=0.6.0)"] +xls = ["pyexcel-xls (>=0.6.0)"] +xlsx = ["pyexcel-xlsx (>=0.6.0)"] + +[[package]] +name = "pyexcel-ods3" +version = "0.6.1" +description = "A wrapper library to read, manipulate and write data in ods format" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +lxml = "*" +pyexcel-ezodf = ">=0.3.3" +pyexcel-io = ">=0.6.2" + +[[package]] +name = "pyexcel-webio" +version = "0.1.4" +description = "A generic request and response interface for pyexcel web extensions." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pyexcel = ">=0.5.6" + +[[package]] +name = "pyexcel-xlsx" +version = "0.6.0" +description = "A wrapper library to read, manipulate and write data in xlsx and xlsmformat" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +openpyxl = ">=2.6.1" +pyexcel-io = ">=0.6.2" + +[[package]] +name = "pyflakes" +version = "2.4.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.13.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyopenssl" +version = "22.1.0" +description = "Python wrapper module around the OpenSSL library" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = ">=38.0.0,<39" + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyreadline" +version = "2.1" +description = "A python implmementation of GNU readline." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pyrepl" +version = "0.9.0" +description = "A library for building flexible command line interfaces" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "3.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-django" +version = "4.5.2" +description = "A Django plugin for pytest." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["Django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-env" +version = "0.6.2" +description = "py.test plugin that allows you to add environment variables." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=2.6.0" + +[[package]] +name = "pytest-factoryboy" +version = "2.1.0" +description = "Factory Boy support for pytest." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +factory-boy = ">=2.10.0" +inflection = "*" +pytest = ">=4.6" + +[[package]] +name = "pytest-freezegun" +version = "0.4.2" +description = "Wrap tests with fixtures in freeze_time" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +freezegun = ">0.3" +pytest = ">=3.0.0" + +[[package]] +name = "pytest-mock" +version = "3.7.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-randomly" +version = "3.12.0" +description = "Pytest plugin to randomly order tests and control random.seed." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +pytest = "*" + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-redmine" +version = "2.3.0" +description = "Library for communicating with a Redmine project management application" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +requests = ">=2.23.0" + +[[package]] +name = "pytz" +version = "2022.5" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-mock" +version = "1.9.3" +description = "Mock out responses from the requests package" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.3,<3" +six = "*" + +[package.extras] +fixture = ["fixtures"] +test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.18)", "testtools"] + +[[package]] +name = "sentry-sdk" +version = "1.10.1" +description = "Python client for Sentry (https://sentry.io)" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +chalice = ["chalice (>=1.16.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)"] +httpx = ["httpx (>=0.16.0)"] +pure-eval = ["asttokens", "executing", "pure-eval"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "setuptools" +version = "65.5.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "snapshottest" +version = "0.6.0" +description = "Snapshot testing for pytest, unittest, Django, and Nose" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +fastdiff = ">=0.1.4,<1" +six = ">=1.10.0" +termcolor = "*" + +[package.extras] +nose = ["nose"] +pytest = ["pytest"] +test = ["django (>=1.10.6)", "nose", "pytest (>=4.6)", "pytest-cov", "six"] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "sqlparse" +version = "0.4.3" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "stack-data" +version = "0.5.1" +description = "Extract data from python stack frames and tracebacks for informative displays" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +asttokens = "*" +executing = "*" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "termcolor" +version = "2.0.1" +description = "ANSI color formatting for output in terminal" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "testfixtures" +version = "6.18.5" +description = "A collection of helpers and mock objects for unit tests and doc tests." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +build = ["setuptools-git", "twine", "wheel"] +docs = ["django", "django (<2)", "mock", "sphinx", "sybil", "twisted", "zope.component"] +test = ["django", "django (<2)", "mock", "pytest (>=3.6)", "pytest-cov", "pytest-django", "sybil", "twisted", "zope.component"] + +[[package]] +name = "texttable" +version = "1.6.4" +description = "module for creating simple ASCII tables" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "traitlets" +version = "5.5.0" +description = "" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["pre-commit", "pytest"] + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "urllib3" +version = "1.26.12" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "wasmer" +version = "1.1.0" +description = "Python extension to run WebAssembly binaries" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "wasmer-compiler-cranelift" +version = "1.1.0" +description = "The Cranelift compiler for the `wasmer` package (to compile WebAssembly module)" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "whitenoise" +version = "6.2.0" +description = "Radically simplified static file serving for WSGI applications" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +brotli = ["Brotli"] + +[[package]] +name = "wmctrl" +version = "0.4" +description = "A tool to programmatically control windows inside X" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "zipp" +version = "3.10.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.8" +content-hash = "98d16983905efba85b5de04c9a610fac20e630c4d5574d4d4c08c6854490e47c" + +[metadata.files] +appnope = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] +asgiref = [ + {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, + {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, +] +asttokens = [ + {file = "asttokens-2.0.8-py2.py3-none-any.whl", hash = "sha256:e3305297c744ae53ffa032c45dc347286165e4ffce6875dc662b205db0623d86"}, + {file = "asttokens-2.0.8.tar.gz", hash = "sha256:c61e16246ecfb2cde2958406b4c8ebc043c9e6d73aaa83c941673b35e5d3a76b"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, +] +attrs = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] +babel = [ + {file = "Babel-2.10.3-py3-none-any.whl", hash = "sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb"}, + {file = "Babel-2.10.3.tar.gz", hash = "sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51"}, +] +backcall = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] +black = [ + {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, + {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, + {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, + {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, + {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, + {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, + {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, + {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, + {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, + {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, + {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, + {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, + {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, + {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, + {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, + {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, + {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, + {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, + {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, +] +certifi = [ + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, +] +cffi = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] +chardet = [ + {file = "chardet-5.0.0-py3-none-any.whl", hash = "sha256:d3e64f022d254183001eccc5db4040520c0f23b1a3f33d6413e099eb7f126557"}, + {file = "chardet-5.0.0.tar.gz", hash = "sha256:0368df2bfd78b5fc20572bb4e9bb7fb53e2c094f60ae9993339e8671d0afb8aa"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +coverage = [ + {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, + {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, + {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, + {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, + {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, + {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, + {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, + {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, + {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, + {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, + {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, + {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, + {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, +] +cryptography = [ + {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:10d1f29d6292fc95acb597bacefd5b9e812099d75a6469004fd38ba5471a977f"}, + {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3fc26e22840b77326a764ceb5f02ca2d342305fba08f002a8c1f139540cdfaad"}, + {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b72c360427889b40f36dc214630e688c2fe03e16c162ef0aa41da7ab1455153"}, + {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:194044c6b89a2f9f169df475cc167f6157eb9151cc69af8a2a163481d45cc407"}, + {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca9f6784ea96b55ff41708b92c3f6aeaebde4c560308e5fbbd3173fbc466e94e"}, + {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:16fa61e7481f4b77ef53991075de29fc5bacb582a1244046d2e8b4bb72ef66d0"}, + {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d4ef6cc305394ed669d4d9eebf10d3a101059bdcf2669c366ec1d14e4fb227bd"}, + {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3261725c0ef84e7592597606f6583385fed2a5ec3909f43bc475ade9729a41d6"}, + {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0297ffc478bdd237f5ca3a7dc96fc0d315670bfa099c04dc3a4a2172008a405a"}, + {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89ed49784ba88c221756ff4d4755dbc03b3c8d2c5103f6d6b4f83a0fb1e85294"}, + {file = "cryptography-38.0.1-cp36-abi3-win32.whl", hash = "sha256:ac7e48f7e7261207d750fa7e55eac2d45f720027d5703cd9007e9b37bbb59ac0"}, + {file = "cryptography-38.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ad7353f6ddf285aeadfaf79e5a6829110106ff8189391704c1d8801aa0bae45a"}, + {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:896dd3a66959d3a5ddcfc140a53391f69ff1e8f25d93f0e2e7830c6de90ceb9d"}, + {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d3971e2749a723e9084dd507584e2a2761f78ad2c638aa31e80bc7a15c9db4f9"}, + {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:79473cf8a5cbc471979bd9378c9f425384980fcf2ab6534b18ed7d0d9843987d"}, + {file = "cryptography-38.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:d9e69ae01f99abe6ad646947bba8941e896cb3aa805be2597a0400e0764b5818"}, + {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5067ee7f2bce36b11d0e334abcd1ccf8c541fc0bbdaf57cdd511fdee53e879b6"}, + {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:3e3a2599e640927089f932295a9a247fc40a5bdf69b0484532f530471a382750"}, + {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2e5856248a416767322c8668ef1845ad46ee62629266f84a8f007a317141013"}, + {file = "cryptography-38.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:64760ba5331e3f1794d0bcaabc0d0c39e8c60bf67d09c93dc0e54189dfd7cfe5"}, + {file = "cryptography-38.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b6c9b706316d7b5a137c35e14f4103e2115b088c412140fdbd5f87c73284df61"}, + {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0163a849b6f315bf52815e238bc2b2346604413fa7c1601eea84bcddb5fb9ac"}, + {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d1a5bd52d684e49a36582193e0b89ff267704cd4025abefb9e26803adeb3e5fb"}, + {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:765fa194a0f3372d83005ab83ab35d7c5526c4e22951e46059b8ac678b44fa5a"}, + {file = "cryptography-38.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b"}, + {file = "cryptography-38.0.1.tar.gz", hash = "sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7"}, +] +decorator = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] +django = [ + {file = "Django-3.2.16-py3-none-any.whl", hash = "sha256:18ba8efa36b69cfcd4b670d0fa187c6fe7506596f0ababe580e16909bcdec121"}, + {file = "Django-3.2.16.tar.gz", hash = "sha256:3adc285124244724a394fa9b9839cc8cd116faf7d159554c43ecdaa8cdf0b94d"}, +] +django-cors-headers = [ + {file = "django-cors-headers-3.13.0.tar.gz", hash = "sha256:f9dc6b4e3f611c3199700b3e5f3398c28757dcd559c2f82932687f3d0443cfdf"}, + {file = "django_cors_headers-3.13.0-py3-none-any.whl", hash = "sha256:37e42883b5f1f2295df6b4bba96eb2417a14a03270cb24b2a07f021cd4487cf4"}, +] +django-environ = [ + {file = "django-environ-0.8.1.tar.gz", hash = "sha256:6f0bc902b43891656b20486938cba0861dc62892784a44919170719572a534cb"}, + {file = "django_environ-0.8.1-py2.py3-none-any.whl", hash = "sha256:42593bee519a527602a467c7b682aee1a051c2597f98c45f4f4f44169ecdb6e5"}, +] +django-excel = [ + {file = "django-excel-0.0.10.tar.gz", hash = "sha256:81cd3bce8007009c30205f7085a97f2908557014900775577cab0b9a770c2bad"}, + {file = "django_excel-0.0.10-py2.py3-none-any.whl", hash = "sha256:f0297202fc460eb74657f8a9d4473921050fbe2e297765c174be9cf33d1195c7"}, +] +django-filter = [ + {file = "django-filter-21.1.tar.gz", hash = "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e"}, + {file = "django_filter-21.1-py3-none-any.whl", hash = "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"}, +] +django-money = [ + {file = "django-money-2.1.1.tar.gz", hash = "sha256:4d06041fac5c565ad049a7f8fb4bc33de5c68047b0693efa18a9931cebffb606"}, + {file = "django_money-2.1.1-py3-none-any.whl", hash = "sha256:60a605d5b999e1756a18008dd7e0c5a860fd64c018a140c7a7675a4209ec3782"}, +] +django-multiselectfield = [ + {file = "django-multiselectfield-0.1.12.tar.gz", hash = "sha256:d0a4c71568fb2332c71478ffac9f8708e01314a35cf923dfd7a191343452f9f9"}, + {file = "django_multiselectfield-0.1.12-py3-none-any.whl", hash = "sha256:c270faa7f80588214c55f2d68cbddb2add525c2aa830c216b8a198de914eb470"}, +] +django-nested-inline = [ + {file = "django-nested-inline-0.4.5.tar.gz", hash = "sha256:d7e51dc1ebf805df53ec7ff9b4108dfe1dfb8a7e02212700a4e467f2caafe34b"}, + {file = "django_nested_inline-0.4.5-py3-none-any.whl", hash = "sha256:fc6998e7d607c1414e25897ae1a544105ada93d63c5cff3ac9582fbf14d8ec63"}, +] +django-prometheus = [ + {file = "django-prometheus-2.2.0.tar.gz", hash = "sha256:240378a1307c408bd5fc85614a3a57f1ce633d4a222c9e291e2bbf325173b801"}, + {file = "django_prometheus-2.2.0-py2.py3-none-any.whl", hash = "sha256:e6616770d8820b8834762764bf1b76ec08e1b98e72a6f359d488a2e15fe3537c"}, +] +djangorestframework = [ + {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, + {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, +] +djangorestframework-jsonapi = [ + {file = "djangorestframework-jsonapi-5.0.0.tar.gz", hash = "sha256:090c568dc99380ead71cc378020b4cd191db2ffce9ab3e9339df80d5d82c8648"}, + {file = "djangorestframework_jsonapi-5.0.0-py2.py3-none-any.whl", hash = "sha256:f25b0d24a990690e578668b7a7a191a75162f1d9561abd773d12de331cf16673"}, +] +et-xmlfile = [ + {file = "et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"}, + {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, +] +executing = [ + {file = "executing-1.1.1-py2.py3-none-any.whl", hash = "sha256:236ea5f059a38781714a8bfba46a70fad3479c2f552abee3bbafadc57ed111b8"}, + {file = "executing-1.1.1.tar.gz", hash = "sha256:b0d7f8dcc2bac47ce6e39374397e7acecea6fdc380a6d5323e26185d70f38ea8"}, +] +factory-boy = [ + {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, + {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, +] +faker = [ + {file = "Faker-15.1.1-py3-none-any.whl", hash = "sha256:096c15e136adb365db24d8c3964fe26bfc68fe060c9385071a339f8c14e09c8a"}, + {file = "Faker-15.1.1.tar.gz", hash = "sha256:a741b77f484215c3aab2604100669657189548f440fcb2ed0f8b7ee21c385629"}, +] +fancycompleter = [ + {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, + {file = "fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272"}, +] +fastdiff = [ + {file = "fastdiff-0.3.0-py2.py3-none-any.whl", hash = "sha256:ca5f61f6ddf5a1564ddfd98132ad28e7abe4a88a638a8b014a2214f71e5918ec"}, + {file = "fastdiff-0.3.0.tar.gz", hash = "sha256:4dfa09c47832a8c040acda3f1f55fc0ab4d666f0e14e6951e6da78d59acd945a"}, +] +flake8 = [ + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, +] +flake8-blind-except = [ + {file = "flake8-blind-except-0.2.1.tar.gz", hash = "sha256:f25a575a9dcb3eeb3c760bf9c22db60b8b5a23120224ed1faa9a43f75dd7dd16"}, +] +flake8-debugger = [ + {file = "flake8-debugger-4.1.2.tar.gz", hash = "sha256:52b002560941e36d9bf806fca2523dc7fb8560a295d5f1a6e15ac2ded7a73840"}, + {file = "flake8_debugger-4.1.2-py3-none-any.whl", hash = "sha256:0a5e55aeddcc81da631ad9c8c366e7318998f83ff00985a49e6b3ecf61e571bf"}, +] +flake8-deprecated = [ + {file = "flake8-deprecated-1.3.tar.gz", hash = "sha256:9fa5a0c5c81fb3b34c53a0e4f16cd3f0a3395078cfd4988011cbab5fb0afa7f7"}, + {file = "flake8_deprecated-1.3-py2.py3-none-any.whl", hash = "sha256:211951854837ced9ec997a75c6e5b957f3536a735538ee0620b76539fd3706cd"}, +] +flake8-docstrings = [ + {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, + {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, +] +flake8-isort = [ + {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, + {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, +] +flake8-string-format = [ + {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, + {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, +] +freezegun = [ + {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, + {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, +] +gunicorn = [ + {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, + {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, +] +idna = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] +importlib-metadata = [ + {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, + {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, +] +inflection = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +ipdb = [ + {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, +] +ipython = [ + {file = "ipython-8.5.0-py3-none-any.whl", hash = "sha256:6f090e29ab8ef8643e521763a4f1f39dc3914db643122b1e9d3328ff2e43ada2"}, + {file = "ipython-8.5.0.tar.gz", hash = "sha256:097bdf5cd87576fd066179c9f7f208004f7a6864ee1b20f37d346c0bcb099f84"}, +] +isort = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] +jedi = [ + {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, + {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, +] +josepy = [ + {file = "josepy-1.13.0-py2.py3-none-any.whl", hash = "sha256:6f64eb35186aaa1776b7a1768651b1c616cab7f9685f9660bffc6491074a5390"}, + {file = "josepy-1.13.0.tar.gz", hash = "sha256:8931daf38f8a4c85274a0e8b7cb25addfd8d1f28f9fb8fbed053dd51aec75dc9"}, +] +lml = [ + {file = "lml-0.1.0-py2.py3-none-any.whl", hash = "sha256:ec06e850019942a485639c8c2a26bdb99eae24505bee7492b649df98a0bed101"}, + {file = "lml-0.1.0.tar.gz", hash = "sha256:57a085a29bb7991d70d41c6c3144c560a8e35b4c1030ffb36d85fa058773bcc5"}, +] +lxml = [ + {file = "lxml-4.9.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed"}, + {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc"}, + {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc"}, + {file = "lxml-4.9.1-cp27-cp27m-win32.whl", hash = "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3"}, + {file = "lxml-4.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627"}, + {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84"}, + {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8"}, + {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8"}, + {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d"}, + {file = "lxml-4.9.1-cp310-cp310-win32.whl", hash = "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7"}, + {file = "lxml-4.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b"}, + {file = "lxml-4.9.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d"}, + {file = "lxml-4.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3"}, + {file = "lxml-4.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29"}, + {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d"}, + {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318"}, + {file = "lxml-4.9.1-cp35-cp35m-win32.whl", hash = "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7"}, + {file = "lxml-4.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4"}, + {file = "lxml-4.9.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf"}, + {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3"}, + {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391"}, + {file = "lxml-4.9.1-cp36-cp36m-win32.whl", hash = "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e"}, + {file = "lxml-4.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7"}, + {file = "lxml-4.9.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca"}, + {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785"}, + {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785"}, + {file = "lxml-4.9.1-cp37-cp37m-win32.whl", hash = "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a"}, + {file = "lxml-4.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e"}, + {file = "lxml-4.9.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715"}, + {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036"}, + {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387"}, + {file = "lxml-4.9.1-cp38-cp38-win32.whl", hash = "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94"}, + {file = "lxml-4.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345"}, + {file = "lxml-4.9.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000"}, + {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25"}, + {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd"}, + {file = "lxml-4.9.1-cp39-cp39-win32.whl", hash = "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb"}, + {file = "lxml-4.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73"}, + {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c"}, + {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"}, + {file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"}, +] +matplotlib-inline = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mozilla-django-oidc = [ + {file = "mozilla-django-oidc-2.0.0.tar.gz", hash = "sha256:a8b2f27c69c122d2f4d801c3759761d33debf06ae9dabbab8aed82887bba3bb8"}, + {file = "mozilla_django_oidc-2.0.0-py2.py3-none-any.whl", hash = "sha256:53c39755b667e8c5923b1dffc3c29673198d03aa107aa42ac86b8a38b4720c25"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +openpyxl = [ + {file = "openpyxl-3.0.10-py2.py3-none-any.whl", hash = "sha256:0ab6d25d01799f97a9464630abacbb34aafecdcaa0ef3cba6d6b3499867d0355"}, + {file = "openpyxl-3.0.10.tar.gz", hash = "sha256:e47805627aebcf860edb4edf7987b1309c1b3632f3750538ed962bbcc3bd7449"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +parso = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] +pathspec = [ + {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, + {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, +] +pdbpp = [ + {file = "pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1"}, + {file = "pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5"}, +] +pexpect = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] +pickleshare = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] +platformdirs = [ + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +prometheus-client = [ + {file = "prometheus_client-0.15.0-py3-none-any.whl", hash = "sha256:db7c05cbd13a0f79975592d112320f2605a325969b270a94b71dcabc47b931d2"}, + {file = "prometheus_client-0.15.0.tar.gz", hash = "sha256:be26aa452490cfcf6da953f9436e95a9f2b4d578ca80094b4458930e5f584ab1"}, +] +prompt-toolkit = [ + {file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"}, + {file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"}, +] +psycopg2-binary = [ + {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:0775d6252ccb22b15da3b5d7adbbf8cfe284916b14b6dc0ff503a23edb01ee85"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3520d7af1ebc838cc6084a3281145d5cd5bdd43fdef139e6db5af01b92596cb7"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cbc554ba47ecca8cd3396ddaca85e1ecfe3e48dd57dc5e415e59551affe568e"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:5d28ecdf191db558d0c07d0f16524ee9d67896edf2b7990eea800abeb23ebd61"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05b3d479425e047c848b9782cd7aac9c6727ce23181eb9647baf64ffdfc3da41"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e491e6489a6cb1d079df8eaa15957c277fdedb102b6a68cfbf40c4994412fd0"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:9e32cedc389bcb76d9f24ea8a012b3cb8385ee362ea437e1d012ffaed106c17d"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:af0516e1711995cb08dc19bbd05bec7dbdebf4185f68870595156718d237df3e"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e72c91bda9880f097c8aa3601a2c0de6c708763ba8128006151f496ca9065935"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:56b2957a145f816726b109ee3d4e6822c23f919a7d91af5a94593723ed667835"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:70831e03bd53702c941da1a1ad36c17d825a24fbb26857b40913d58df82ec18b"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dbc332beaf8492b5731229a881807cd7b91b50dbbbaf7fe2faf46942eda64a24"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:95076399ec3b27a8f7fa1cc9a83417b1c920d55cf7a97f718a94efbb96c7f503"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-win32.whl", hash = "sha256:3fc33295cfccad697a97a76dec3f1e94ad848b7b163c3228c1636977966b51e2"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-win_amd64.whl", hash = "sha256:02551647542f2bf89073d129c73c05a25c372fc0a49aa50e0de65c3c143d8bd0"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:63e318dbe52709ed10d516a356f22a635e07a2e34c68145484ed96a19b0c4c68"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e518a0911c50f60313cb9e74a169a65b5d293770db4770ebf004245f24b5c5"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:68d81a2fe184030aa0c5c11e518292e15d342a667184d91e30644c9d533e53e1"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:7ee3095d02d6f38bd7d9a5358fcc9ea78fcdb7176921528dd709cc63f40184f5"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:46512486be6fbceef51d7660dec017394ba3e170299d1dc30928cbedebbf103a"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b911dfb727e247340d36ae20c4b9259e4a64013ab9888ccb3cbba69b77fd9636"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:422e3d43b47ac20141bc84b3d342eead8d8099a62881a501e97d15f6addabfe9"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-win32.whl", hash = "sha256:b8104f709590fff72af801e916817560dbe1698028cd0afe5a52d75ceb1fce5f"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-win_amd64.whl", hash = "sha256:7b3751857da3e224f5629400736a7b11e940b5da5f95fa631d86219a1beaafec"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:043a9fd45a03858ff72364b4b75090679bd875ee44df9c0613dc862ca6b98460"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9ffdc51001136b699f9563b1c74cc1f8c07f66ef7219beb6417a4c8aaa896c28"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc85b3777068ed30aff8242be2813038a929f2084f69e43ef869daddae50f6ee"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:7d07f552d1e412f4b4e64ce386d4c777a41da3b33f7098b6219012ba534fb2c2"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a0adef094c49f242122bb145c3c8af442070dc0e4312db17e49058c1702606d4"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00475004e5ed3e3bf5e056d66e5dcdf41a0dc62efcd57997acd9135c40a08a50"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7d88db096fa19d94f433420eaaf9f3c45382da2dd014b93e4bf3215639047c16"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:902844f9c4fb19b17dfa84d9e2ca053d4a4ba265723d62ea5c9c26b38e0aa1e6"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-win32.whl", hash = "sha256:4e7904d1920c0c89105c0517dc7e3f5c20fb4e56ba9cdef13048db76947f1d79"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:a36a0e791805aa136e9cbd0ffa040d09adec8610453ee8a753f23481a0057af5"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9c38d3869238e9d3409239bc05bc27d6b7c99c2a460ea337d2814b35fb4fea1b"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6527c8efa5226a9e787507652dd5ba97b62d29b53c371a85cd13f957fe4d42"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59137cdb970249ae60be2a49774c6dfb015bd0403f05af1fe61862e9626642d"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:d4c7b3a31502184e856df1f7bbb2c3735a05a8ce0ade34c5277e1577738a5c91"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b9a794cef1d9c1772b94a72eec6da144c18e18041d294a9ab47669bc77a80c1d"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5e65c6ac0ae4bf5bef1667029f81010b6017795dcb817ba5c7b8a8d61fab76f"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:74eddec4537ab1f701a1647214734bc52cee2794df748f6ae5908e00771f180a"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:01ad49d68dd8c5362e4bfb4158f2896dc6e0c02e87b8a3770fc003459f1a4425"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-win32.whl", hash = "sha256:937880290775033a743f4836aa253087b85e62784b63fd099ee725d567a48aa1"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:484405b883630f3e74ed32041a87456c5e0e63a8e3429aa93e8714c366d62bd1"}, +] +ptyprocess = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] +pure-eval = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +py-moneyed = [ + {file = "py-moneyed-1.2.tar.gz", hash = "sha256:d745a52819604f42b3666f9b2504b71c27c1645d6d5027d95ec5ed1f28ca86e3"}, + {file = "py_moneyed-1.2-py2.py3-none-any.whl", hash = "sha256:c6131c7b7c1f8503552afe44d15c343ea50282d1d9e6fa8b3f1bd2affc1dae1e"}, +] +pycodestyle = [ + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, +] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] +pydocstyle = [ + {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, + {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, +] +pyexcel = [ + {file = "pyexcel-0.7.0-py2.py3-none-any.whl", hash = "sha256:ddc6904512bfa2ecda509fb3b58229bb30db14498632fd9e7a5ba7bbfb02ed1b"}, + {file = "pyexcel-0.7.0.tar.gz", hash = "sha256:fbf0eee5d93b96cef6f19a9f00703f22c0a64f19728d91b95428009a52129709"}, +] +pyexcel-ezodf = [ + {file = "pyexcel-ezodf-0.3.4.tar.gz", hash = "sha256:972eeea9b0e4bab60dfc5cdcb7378cc7ba5e070a0b7282746c0182c5de011ff1"}, + {file = "pyexcel_ezodf-0.3.4-py2.py3-none-any.whl", hash = "sha256:a74ac7636a015fff31d35c5350dc5ad347ba98ecb453de4dbcbb9a9168434e8c"}, +] +pyexcel-io = [ + {file = "pyexcel-io-0.6.6.tar.gz", hash = "sha256:f6084bf1afa5fbf4c61cf7df44370fa513821af188b02e3e19b5efb66d8a969f"}, + {file = "pyexcel_io-0.6.6-py2.py3-none-any.whl", hash = "sha256:19ff1d599a8a6c0982e4181ef86aa50e1f8d231410fa7e0e204d62e37551c1d6"}, +] +pyexcel-ods3 = [ + {file = "pyexcel-ods3-0.6.1.tar.gz", hash = "sha256:53740fc9bc6e91e43cdc0ee4f557bb3b252d8493d34f2c11d26a93c53cfebc2e"}, + {file = "pyexcel_ods3-0.6.1-py3-none-any.whl", hash = "sha256:ca61d139879349a5d4b0a241add6504474c59fa280d1804b76f56ee4ba30eb8b"}, +] +pyexcel-webio = [ + {file = "pyexcel-webio-0.1.4.tar.gz", hash = "sha256:039538f1b35351f1632891dde29ef4d7fba744e217678ebb5a501336e28ca265"}, + {file = "pyexcel_webio-0.1.4-py2.py3-none-any.whl", hash = "sha256:3583cf7dcddb747520a8a90e93cf07b0584878b56b3c41c46d132b458a6cfd00"}, +] +pyexcel-xlsx = [ + {file = "pyexcel-xlsx-0.6.0.tar.gz", hash = "sha256:55754f764252461aca6871db203f4bd1370ec877828e305e6be1de5f9aa6a79d"}, + {file = "pyexcel_xlsx-0.6.0-py2.py3-none-any.whl", hash = "sha256:16530f96a77c97ebcba7941517d2756ac52d3ce2903d81eecd7f300778d5242a"}, +] +pyflakes = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] +pygments = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, +] +pyopenssl = [ + {file = "pyOpenSSL-22.1.0-py3-none-any.whl", hash = "sha256:b28437c9773bb6c6958628cf9c3bebe585de661dba6f63df17111966363dd15e"}, + {file = "pyOpenSSL-22.1.0.tar.gz", hash = "sha256:7a83b7b272dd595222d672f5ce29aa030f1fb837630ef229f62e72e395ce8968"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pyreadline = [ + {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, +] +pyrepl = [ + {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, +] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] +pytest-cov = [ + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, +] +pytest-django = [ + {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, + {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, +] +pytest-env = [ + {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"}, +] +pytest-factoryboy = [ + {file = "pytest-factoryboy-2.1.0.tar.gz", hash = "sha256:23bc562ab32cc39eddfbbbf70e618a1b30e834a4cfa451c4bedc36216f0a7b19"}, + {file = "pytest_factoryboy-2.1.0-py3-none-any.whl", hash = "sha256:10c02d2736cb52c7af28065db9617e7f50634e95eaa07eeb9a007026aa3dc0a8"}, +] +pytest-freezegun = [ + {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"}, + {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"}, +] +pytest-mock = [ + {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, + {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, +] +pytest-randomly = [ + {file = "pytest-randomly-3.12.0.tar.gz", hash = "sha256:d60c2db71ac319aee0fc6c4110a7597d611a8b94a5590918bfa8583f00caccb2"}, + {file = "pytest_randomly-3.12.0-py3-none-any.whl", hash = "sha256:f4f2e803daf5d1ba036cc22bf4fe9dbbf99389ec56b00e5cba732fb5c1d07fdd"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +python-redmine = [ + {file = "python-redmine-2.3.0.tar.gz", hash = "sha256:96889d1ae59b5830337e2c2fff1e2ce54103e52bbb632bd7c648f7d2d0274d25"}, + {file = "python_redmine-2.3.0-py2.py3-none-any.whl", hash = "sha256:502680473a3f9b7a001f788969c62081023afa83d896c0a54963aed3cf198b89"}, +] +pytz = [ + {file = "pytz-2022.5-py2.py3-none-any.whl", hash = "sha256:335ab46900b1465e714b4fda4963d87363264eb662aab5e65da039c25f1f5b22"}, + {file = "pytz-2022.5.tar.gz", hash = "sha256:c4d88f472f54d615e9cd582a5004d1e5f624854a6a27a6211591c251f22a6914"}, +] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +requests-mock = [ + {file = "requests-mock-1.9.3.tar.gz", hash = "sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba"}, + {file = "requests_mock-1.9.3-py2.py3-none-any.whl", hash = "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970"}, +] +sentry-sdk = [ + {file = "sentry-sdk-1.10.1.tar.gz", hash = "sha256:105faf7bd7b7fa25653404619ee261527266b14103fe1389e0ce077bd23a9691"}, + {file = "sentry_sdk-1.10.1-py2.py3-none-any.whl", hash = "sha256:06c0fa9ccfdc80d7e3b5d2021978d6eb9351fa49db9b5847cf4d1f2a473414ad"}, +] +setuptools = [ + {file = "setuptools-65.5.0-py3-none-any.whl", hash = "sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"}, + {file = "setuptools-65.5.0.tar.gz", hash = "sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +snapshottest = [ + {file = "snapshottest-0.6.0-py2.py3-none-any.whl", hash = "sha256:9b177cffe0870c589df8ddbee0a770149c5474b251955bdbde58b7f32a4ec429"}, + {file = "snapshottest-0.6.0.tar.gz", hash = "sha256:bbcaf81d92d8e330042e5c928e13d9f035e99e91b314fe55fda949c2f17b653c"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] +sqlparse = [ + {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, + {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, +] +stack-data = [ + {file = "stack_data-0.5.1-py3-none-any.whl", hash = "sha256:5120731a18ba4c82cefcf84a945f6f3e62319ef413bfc210e32aca3a69310ba2"}, + {file = "stack_data-0.5.1.tar.gz", hash = "sha256:95eb784942e861a3d80efd549ff9af6cf847d88343a12eb681d7157cfcb6e32b"}, +] +termcolor = [ + {file = "termcolor-2.0.1-py3-none-any.whl", hash = "sha256:7e597f9de8e001a3208c4132938597413b9da45382b6f1d150cff8d062b7aaa3"}, + {file = "termcolor-2.0.1.tar.gz", hash = "sha256:6b2cf769e93364a2676e1de56a7c0cff2cf5bd07f37e9cc80b0dd6320ebfe388"}, +] +testfixtures = [ + {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"}, + {file = "testfixtures-6.18.5.tar.gz", hash = "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84"}, +] +texttable = [ + {file = "texttable-1.6.4-py2.py3-none-any.whl", hash = "sha256:dd2b0eaebb2a9e167d1cefedab4700e5dcbdb076114eed30b58b97ed6b37d6f2"}, + {file = "texttable-1.6.4.tar.gz", hash = "sha256:42ee7b9e15f7b225747c3fa08f43c5d6c83bc899f80ff9bae9319334824076e9"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +traitlets = [ + {file = "traitlets-5.5.0-py3-none-any.whl", hash = "sha256:1201b2c9f76097195989cdf7f65db9897593b0dfd69e4ac96016661bb6f0d30f"}, + {file = "traitlets-5.5.0.tar.gz", hash = "sha256:b122f9ff2f2f6c1709dab289a05555be011c87828e911c0cf4074b85cb780a79"}, +] +typing-extensions = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] +urllib3 = [ + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, +] +wasmer = [ + {file = "wasmer-1.1.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:c2af4b907ae2dabcac41e316e811d5937c93adf1f8b05c5d49427f8ce0f37630"}, + {file = "wasmer-1.1.0-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:ab1ae980021e5ec0bf0c6cdd3b979b1d15a5f3eb2b8a32da8dcb1156e4a1e484"}, + {file = "wasmer-1.1.0-cp310-none-win_amd64.whl", hash = "sha256:d0d93aec6215893d33e803ef0a8d37bf948c585dd80ba0e23a83fafee820bc03"}, + {file = "wasmer-1.1.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:1e63d16bd6e2e2272d8721647831de5c537e0bb08002ee6d7abf167ec02d5178"}, + {file = "wasmer-1.1.0-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:85e6a5bf44853e8e6a12e947ee3412da9e84f7ce49fc165ba5dbd293e9c5c405"}, + {file = "wasmer-1.1.0-cp37-none-win_amd64.whl", hash = "sha256:a182a6eca9b46d895b4985fc822fab8da3d2f84fab74ca27e55a7430a7fcf336"}, + {file = "wasmer-1.1.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:214d9a3cfb577ea9449eb2b5f13adceae34c55365e4c3d930066beb86a7f67bc"}, + {file = "wasmer-1.1.0-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:b9e5605552bd7d2bc6337519b176defe83bc69b98abf3caaaefa4f7ec231d18a"}, + {file = "wasmer-1.1.0-cp38-none-win_amd64.whl", hash = "sha256:20b5190112e2e94a8947967f2bc683c9685855d0f34130d8434c87a55216a3bd"}, + {file = "wasmer-1.1.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:ee442f0970f40ec5e32011c92fd753fb2061da0faa13de13fafc730c31be34e3"}, + {file = "wasmer-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa112198b743cff2e391230436813fb4b244a24443e37866522b7197e3a034da"}, + {file = "wasmer-1.1.0-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:c0b37117f6d3ff51ee96431c7d224d99799b08d174e30fcd0fcd7e2e3cb8140c"}, + {file = "wasmer-1.1.0-cp39-none-win_amd64.whl", hash = "sha256:a0a4730ec4907a4cb0d9d4a77ea2608c2c814f22a22b73fc80be0f110e014836"}, + {file = "wasmer-1.1.0-py3-none-any.whl", hash = "sha256:2caf8c67feae9cd4246421551036917811c446da4f27ad4c989521ef42751931"}, +] +wasmer-compiler-cranelift = [ + {file = "wasmer_compiler_cranelift-1.1.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:9869910179f39696a020edc5689f7759257ac1cce569a7a0fcf340c59788baad"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:405546ee864ac158a4107f374dfbb1c8d6cfb189829bdcd13050143a4bd98f28"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp310-none-win_amd64.whl", hash = "sha256:bdf75af9ef082e6aeb752550f694273340ece970b65099e0746db0f972760d11"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:7d9c782b7721789b16e303b7e70c59df370896dd62b77e2779e3a44b4e1aa20c"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:ff7dd5bd69030b63521c24583bf0f5457cd2580237340b91ce35370f72a4a1cc"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp37-none-win_amd64.whl", hash = "sha256:447285402e366a34667a674db70458c491acd6940b797c175c0b0027f48e64bb"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:55a524985179f6b7b88ac973e8fac5a2574d3b125a966fba75fedd5a2525e484"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:bd03db5a916ead51b442c66acad38847dfe127cf90b2019b1680f1920c4f8d06"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp38-none-win_amd64.whl", hash = "sha256:157d87cbd1d04adbad55b50cb4bedc28e444caf74797fd96df17390667e58699"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:ff25fc99ebafa04a6c271d08a90d17b927930e3019a2b333c7cfb48ba32c6f71"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9697ae082317a56776df8ff7df8c922eac38488ef38d3478fe5f0ca144c185ab"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:2a4349b1ddd727bd46bc5ede741839dcfc5153c52f064a83998c4150d5d4a85c"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-none-win_amd64.whl", hash = "sha256:32fe38614fccc933da77ee4372904a5fa9c12b859209a2e4048a8284c4c371f2"}, + {file = "wasmer_compiler_cranelift-1.1.0-py3-none-any.whl", hash = "sha256:200fea80609cfb088457327acf66d5aa61f4c4f66b5a71133ada960b534c7355"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +whitenoise = [ + {file = "whitenoise-6.2.0-py3-none-any.whl", hash = "sha256:8e9c600a5c18bd17655ef668ad55b5edf6c24ce9bdca5bf607649ca4b1e8e2c2"}, + {file = "whitenoise-6.2.0.tar.gz", hash = "sha256:8fa943c6d4cd9e27673b70c21a07b0aa120873901e099cd46cab40f7cc96d567"}, +] +wmctrl = [ + {file = "wmctrl-0.4.tar.gz", hash = "sha256:66cbff72b0ca06a22ec3883ac3a4d7c41078bdae4fb7310f52951769b10e14e0"}, +] +zipp = [ + {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, + {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..662fcb2a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,130 @@ +[tool.poetry] +name = "timed-backend" +version = "2.0.0" +description = "Timetracking software" +repository = "https://github.com/adfinis/timed-backend" +authors = ["Adfinis AG"] +license = "AGPL-3.0" +readme = "README.md" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.8", +] +include = ["CHANGELOG.md"] + +[tool.poetry.dependencies] +python = "^3.8" +python-dateutil = "^2.8.2" +django = "^3.2.13" +# might remove this once we find out how the jsonapi extras_require work +django-cors-headers = "^3.13.0" +django-filter = "^21.1" +django-multiselectfield = "^0.1.12" +django-prometheus = "^2.2.0" +djangorestframework = "^3.13.1" +djangorestframework-jsonapi = "^5.0.0" +mozilla-django-oidc = "^2.0.0" +psycopg2-binary = "^2.9.3" +pytz = "^2022.1" +pyexcel-webio = "^0.1.4" +pyexcel-io = "^0.6.6" +django-excel = "^0.0.10" +django-nested-inline = "^0.4.5" +pyexcel-ods3 = "^0.6.1" +pyexcel-xlsx = "^0.6.0" +pyexcel-ezodf = "^0.3.4" +django-environ = "^0.8.1" +django-money = "^2.1.1" +python-redmine = "^2.3.0" +sentry-sdk = "^1.9.5" +gunicorn = "^20.1.0" +whitenoise = "^6.2.0" + +[tool.poetry.dev-dependencies] +black = "22.3.0" +coverage = "6.4.1" +factory-boy = "3.2.1" +flake8 = "4.0.1" +flake8-blind-except = "0.2.1" +flake8-debugger = "4.1.2" +flake8-deprecated = "1.3" +flake8-docstrings = "1.6.0" +flake8-isort = "4.1.1" +flake8-string-format = "0.3.0" +ipdb = "0.13.9" +isort = "5.10.1" +pdbpp = "0.10.3" +pytest = "7.1.2" +pytest-cov = "3.0.0" +pytest-django = "4.5.2" +pytest-env = "0.6.2" +# needs to stay at 2.1.0 because of wrong interpretation of parameters with "__" +pytest-factoryboy = "2.1.0" +pytest-freezegun = "0.4.2" +pytest-mock = "3.7.0" +pytest-randomly = "3.12.0" +requests-mock = "1.9.3" +snapshottest = "0.6.0" + +[tool.isort] +skip = [ + "migrations", + "snapshots", +] +known_first_party = ["timed"] +known_third_party = ["pytest_factoryboy"] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +combine_as_imports = true +line_length = 88 + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "timed.settings" +addopts = "--reuse-db --randomly-seed=1521188767 --randomly-dont-reorganize" +env = [ + "DJANGO_OIDC_USERNAME_CLAIM=sub" +] +filterwarnings = [ + "error::DeprecationWarning", + "error::PendingDeprecationWarning", + "ignore:Using a non-boolean value for an isnull lookup is deprecated, use True or False instead.:django.utils.deprecation.RemovedInDjango40Warning", + # following is needed beceause of https://github.com/mozilla/mozilla-django-oidc/pull/371 + "ignore:distutils Version classes are deprecated:DeprecationWarning", # deprecation in pytest-freezegun + "ignore:django.conf.urls.url().*:django.utils.deprecation.RemovedInDjango40Warning", + "ignore:.*is deprecated in favour of new moneyed.l10n.format_money.*", +] + +[tool.coverage.run] +source = ["."] + +[tool.coverage.report] +fail_under = 100 +exclude_lines = [ + "pragma: no cover", + "pragma: todo cover", + "def __str__", + "def __unicode__", + "def __repr__", +] +omit = [ + "*/migrations/*", + "*/apps.py", + "*/admin.py", + "manage.py", + "timed/settings_*.py", + "timed/wsgi.py", + "timed/forms.py", + "setup.py", +] +show_missing = true + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index a0691924c..000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,25 +0,0 @@ --r requirements.txt -black==22.3.0 -coverage==6.4.1 -factory-boy==3.2.1 -flake8==4.0.1 -flake8-blind-except==0.2.1 -flake8-debugger==4.1.2 -flake8-deprecated==1.3 -flake8-docstrings==1.6.0 -flake8-isort==4.1.1 -flake8-string-format==0.3.0 -ipdb==0.13.9 -isort==5.10.1 -pdbpp==0.10.3 -pytest==7.1.2 -pytest-cov==3.0.0 -pytest-django==4.5.2 -pytest-env==0.6.2 -# needs to stay at 2.1.0 because of wrong interpretation of parameters with "__" -pytest-factoryboy==2.1.0 -pytest-freezegun==0.4.2 -pytest-mock==3.7.0 -pytest-randomly==3.12.0 -requests-mock==1.9.3 -snapshottest==0.6.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 302de0b9a..000000000 --- a/requirements.txt +++ /dev/null @@ -1,25 +0,0 @@ -python-dateutil==2.8.2 -django==3.2.13 -# might remove this once we find out how the jsonapi extras_require work -django-cors-headers==3.13.0 -django-filter==21.1 -django-multiselectfield==0.1.12 -django-prometheus==2.2.0 -djangorestframework==3.13.1 -djangorestframework-jsonapi[django-filter]==5.0.0 -mozilla-django-oidc==2.0.0 -psycopg2==2.9.3 -pytz==2022.1 -pyexcel-webio==0.1.4 -pyexcel-io==0.6.6 -django-excel==0.0.10 -django-nested-inline==0.4.5 -pyexcel-ods3==0.6.1 -pyexcel-xlsx==0.6.0 -pyexcel-ezodf==0.3.4 -django-environ==0.8.1 -django-money==2.1.1 -python-redmine==2.3.0 -sentry-sdk==1.9.5 -gunicorn==20.1.0 -whitenoise==6.2.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 907f0049f..000000000 --- a/setup.cfg +++ /dev/null @@ -1,78 +0,0 @@ -[flake8] -ignore = - # whitespace before ':' - E203, - # too many leading ### in a block comment - E266, - # line too long (managed by black) - E501, - # Line break occurred before a binary operator (this is not PEP8 compatible) - W503, - # Missing docstring in public module - D100, - # Missing docstring in public class - D101, - # Missing docstring in public method - D102, - # Missing docstring in public function - D103, - # Missing docstring in public package - D104, - # Missing docstring in magic method - D105, - # Missing docstring in public package - D106, - # Missing docstring in __init__ - D107, - # needed because of https://github.com/ambv/black/issues/144 - D202, - # other string does contain unindexed parameters - P103 -max-line-length = 80 -exclude = migrations snapshots -max-complexity = 10 - -[tool:isort] -skip=migrations,snapshots -known_first_party=timed -known_third_party=pytest_factoryboy -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -combine_as_imports=True -line_length=88 - -[tool:pytest] -DJANGO_SETTINGS_MODULE=timed.settings -addopts = --reuse-db --randomly-seed=1521188767 --randomly-dont-reorganize -env= - DJANGO_OIDC_USERNAME_CLAIM=sub -filterwarnings = - error::DeprecationWarning - error::PendingDeprecationWarning - ignore:Using a non-boolean value for an isnull lookup is deprecated, use True or False instead.:django.utils.deprecation.RemovedInDjango40Warning - # following is needed beceause of https://github.com/mozilla/mozilla-django-oidc/pull/371 - ignore:django.conf.urls.url().*:django.utils.deprecation.RemovedInDjango40Warning - ignore:.*is deprecated in favour of new moneyed.l10n.format_money.* - -[coverage:run] -source=. - -[coverage:report] -fail_under=100 -exclude_lines = - pragma: no cover - pragma: todo cover - def __str__ - def __unicode__ - def __repr__ -omit= - */migrations/* - */apps.py - */admin.py - manage.py - timed/settings_*.py - timed/wsgi.py - timed/forms.py - setup.py -show_missing = True diff --git a/setup.py b/setup.py deleted file mode 100644 index a26938294..000000000 --- a/setup.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Setuptools package definition.""" - -import codecs -import os -from collections import defaultdict - -from setuptools import find_packages, setup - -from timed import __version__ - -with codecs.open("README.md", "r", encoding="UTF-8") as f: - README_TEXT = f.read() - - -def find_data(packages, extensions): - """Find data files along with source. - - :param packages: Look in these packages - :param extensions: Look for these extensions - """ - data = defaultdict(list) - for package in packages: - package_path = package.replace(".", "/") - for dirpath, _, filenames in os.walk(package_path): - for filename in filenames: - for extension in extensions: - if filename.endswith(".%s" % extension): - file_path = os.path.join(dirpath, filename) - file_path = file_path[len(package) + 1 :] - data[package].append(file_path) - return data - - -setup( - name="timed", - version=__version__, - author="Adfinis AG", - author_email="https://adfinis.com/", - description="Timetracking software", - long_description=README_TEXT, - install_requires=( - "django>=2.11", - "django-excel", - "django-environ", - "django-money", - "django-filter", - "django-multiselectfield", - "djangorestframework", - "djangorestframework-jsonapi", - "mozilla-django-oidc", - "psycopg2", - "pyexcel-webio", - "pyexcel-io", - "pyexcel-ods3", - "pyexcel-xlsx", - "pyexcel-ezodf", - "python-dateutil", - "python-redmine", - "pytz", - ), - keywords="timetracking", - url="https://adfinis.com/", - packages=find_packages(), - package_data=find_data(find_packages(), ["txt"]), - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: " "GNU Affero General Public License v3", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", - ], -) From fde9d9063686bc0acb4d293361f55b407272e18e Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 10 Oct 2022 10:31:06 +0200 Subject: [PATCH 913/980] chore: add Make commands for easier development --- Makefile | 37 ++++++++++++++++++++++++++++++++++--- poetry.lock | 18 +++++++++++++++++- pyproject.toml | 1 + timed/settings.py | 3 +++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 9fbcb5c0c..8b4edea65 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,57 @@ -.PHONY: help start stop test shell flush loaddata .DEFAULT_GOAL := help +.PHONY: help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' +.PHONY: start start: ## Start the development server @docker-compose up -d --build +.PHONY: stop stop: ## Stop the development server @docker-compose stop +.PHONY: lint +lint: ## Lint the project + @docker-compose exec backend sh -c "poetry run black --check . && poetry run flake8" + +.PHONY: format-code +format-code: ## Format the backend code + @docker-compose exec backend sh -c "poetry run black . && poetry run isort ." + +.PHONY: test test: ## Test the project - @docker-compose exec backend sh -c "poetry run black --check . && poetry run flake8 && poetry run pytest --no-cov-on-fail --cov" + @docker-compose exec backend sh -c "poetry run black . && poetry run isort . && poetry run pytest --no-cov-on-fail --cov -shell: ## Shell into the backend +.PHONY: bash +bash: ## Shell into the backend @docker-compose exec backend bash +.PHONY: dbshell dbshell: ## Start a psql shell @docker-compose exec db psql -Utimed timed +.PHONY: shell_plus +shell_plus: ## Run shell_plus + @docker-compose exec backend poetry run python manage.py shell_plus + +.PHONY: makemigrations +makemigrations: ## Make django migrations + @docker-compose exec backend poetry run python manage.py makemigrations + +.PHONY: migrate +migrate: ## Migrate django + @docker-compose backend poetry run python manage.py migrate + +.PHONY: debug-backend +debug-backend: ## Start backend container with service ports for debugging + @docker-compose run --service-ports backend + +.PHONY: flush flush: ## Flush database contents @docker-compose exec backend poetry run python manage.py flush --no-input +.PHONY: loaddata loaddata: flush ## Loads test data into the database @docker-compose exec backend poetry run python manage.py loaddata timed/fixtures/test_data.json diff --git a/poetry.lock b/poetry.lock index 14d09f856..bc0c8de31 100644 --- a/poetry.lock +++ b/poetry.lock @@ -251,6 +251,17 @@ ods = ["pyexcel-ods3 (>=0.4.0)"] xls = ["pyexcel-xls (>=0.4.0)"] xlsx = ["pyexcel-xlsx (>=0.4.0)"] +[[package]] +name = "django-extensions" +version = "3.2.1" +description = "Extensions for Django" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Django = ">=3.2" + [[package]] name = "django-filter" version = "21.1" @@ -1483,7 +1494,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "98d16983905efba85b5de04c9a610fac20e630c4d5574d4d4c08c6854490e47c" +content-hash = "c4eacd37040bc73ad7a98a03d130028fd93ee9d95cd07289d1d38448a9a25851" [metadata.files] appnope = [ @@ -1715,6 +1726,10 @@ django-excel = [ {file = "django-excel-0.0.10.tar.gz", hash = "sha256:81cd3bce8007009c30205f7085a97f2908557014900775577cab0b9a770c2bad"}, {file = "django_excel-0.0.10-py2.py3-none-any.whl", hash = "sha256:f0297202fc460eb74657f8a9d4473921050fbe2e297765c174be9cf33d1195c7"}, ] +django-extensions = [ + {file = "django-extensions-3.2.1.tar.gz", hash = "sha256:2a4f4d757be2563cd1ff7cfdf2e57468f5f931cc88b23cf82ca75717aae504a4"}, + {file = "django_extensions-3.2.1-py3-none-any.whl", hash = "sha256:421464be390289513f86cb5e18eb43e5dc1de8b4c27ba9faa3b91261b0d67e09"}, +] django-filter = [ {file = "django-filter-21.1.tar.gz", hash = "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e"}, {file = "django_filter-21.1-py3-none-any.whl", hash = "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"}, @@ -1987,6 +2002,7 @@ psycopg2-binary = [ {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"}, {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"}, {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_10_9_universal2.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:426c2ae999135d64e6a18849a7d1ad0e1bd007277e4a8f4752eaa40a96b550ff"}, {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"}, {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"}, {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"}, diff --git a/pyproject.toml b/pyproject.toml index 662fcb2a7..1a6f43a74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ whitenoise = "^6.2.0" [tool.poetry.dev-dependencies] black = "22.3.0" coverage = "6.4.1" +django-extensions = "3.2.1" factory-boy = "3.2.1" flake8 = "4.0.1" flake8-blind-except = "0.2.1" diff --git a/timed/settings.py b/timed/settings.py index 934c448e0..ebf9fe0a5 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -74,6 +74,9 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "timed.subscription", ] +if ENV == "dev": + INSTALLED_APPS.append("django_extensions") + MIDDLEWARE = [ "django_prometheus.middleware.PrometheusBeforeMiddleware", "corsheaders.middleware.CorsMiddleware", From c7c14f016cc238831602f280bcd0e335337b4313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Birrer?= Date: Tue, 8 Nov 2022 12:11:32 +0100 Subject: [PATCH 914/980] refactor(app-server): replace uWSGI with Django Hurricane Hurricane's request queue is configurable with the environment variable `HURRICANE_REQ_QUEUE_LEN` --- Dockerfile | 5 +- README.md | 89 +++++++------- cmd.sh | 14 ++- docker-compose.override.yml | 4 +- poetry.lock | 231 +++++++++++++++++++++--------------- pyproject.toml | 2 +- timed/settings.py | 18 +++ uwsgi.ini | 9 -- 8 files changed, 212 insertions(+), 160 deletions(-) mode change 100755 => 100644 cmd.sh delete mode 100644 uwsgi.ini diff --git a/Dockerfile b/Dockerfile index a7003897c..e6fc128af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,7 @@ ENV DJANGO_SETTINGS_MODULE timed.settings ENV STATIC_ROOT /var/www/static ENV WAITFORIT_TIMEOUT 0 -ENV UWSGI_INI /app/uwsgi.ini -ENV UWSGI_MAX_REQUESTS 2000 -ENV UWSGI_HARAKIRI 5 -ENV UWSGI_PROCESSES 4 +ENV HURRICANE_REQ_QUEUE_LEN 250 RUN pip install -U poetry diff --git a/README.md b/README.md index afe9263bf..8d31b6876 100644 --- a/README.md +++ b/README.md @@ -83,51 +83,50 @@ Save the user and you should now see the _Timed_ interface correctly under that Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) according to type. -| Parameter | Description | Default | -|----------------------------------------------|------------------------------------------------------------------------------------------|--------------------------------------------------------------| -| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | -| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | -| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | -| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | -| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | -| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | -| `DJANGO_DATABASE_NAME` | Database name | timed | -| `DJANGO_DATABASE_USER` | Database username | timed | -| `DJANGO_DATABASE_HOST` | Database hostname | localhost | -| `DJANGO_DATABASE_PORT` | Database port | 5432 | -| `DJANGO_OIDC_DEFAULT_BASE_URL` | Base URL of the OIDC provider | http://timed.local/auth/realms/timed/protocol/openid-connect | -| `DJANGO_OIDC_OP_AUTHORIZATION_ENDPOINT` | OIDC /auth endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/auth | -| `DJANGO_OIDC_OP_TOKEN_ENDPOINT` | OIDC /token endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token | -| `DJANGO_OIDC_OP_USER_ENDPOINT` | OIDC /userinfo endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/userinfo | -| `DJANGO_OIDC_OP_JWKS_ENDPOINT` | OIDC /certs endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/certs | -| `DJANGO_OIDC_RP_CLIENT_ID` | Client ID by your OIDC provider | timed-public | -| `DJANGO_OIDC_RP_CLIENT_SECRET` | Client secret by your OIDC provider, should be None (flow start is handled by frontend) | not set | -| `DJANGO_OIDC_RP_SIGN_ALGO` | Algorithm the OIDC provider uses to sign ID tokens | RS256 | -| `DJANGO_OIDC_VERIFY_SSL` | Verify SSL on OIDC request | dev: False, prod: True | -| `DJANGO_OIDC_CREATE_USER` | Create new user if it doesn't exist in the database | False | -| `DJANGO_OIDC_USERNAME_CLAIM` | Username token claim for user lookup / creation | sub | -| `DJANGO_OIDC_EMAIL_CLAIM` | Email token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | email | -| `DJANGO_OIDC_FIRSTNAME_CLAIM` | First name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | given_name | -| `DJANGO_OIDC_LASTNAME_CLAIM` | Last name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | family_name | -| `DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME` | Time (in seconds) to cache a bearer token before revalidation is needed | 60 | -| `DJANGO_OIDC_CHECK_INTROSPECT` | Use token introspection for confidential clients | True | -| `DJANGO_OIDC_OP_INTROSPECT_ENDPOINT` | OIDC token introspection endpoint (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token/introspect | -| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_ID` | OIDC client id (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | timed-confidential | -| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_SECRET` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | not set | -| `DJANGO_OIDC_ADMIN_LOGIN_REDIRECT_URL` | URL of the django-admin, to which the user is redirected after successful admin login | dev: http://timed.local/admin/, prod: not set | -| `DJANGO_ALLOW_LOCAL_LOGIN` | Enable / Disable login with local user/password (in admin) | True | -| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | -| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | -| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | -| `DJANGO_ADMINS` | List of people who get error notifications | not set | -| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | -| `DJANGO_SENTRY_DSN` | Sentry DSN for error reporting | not set, set to enable Sentry integration | -| `DJANGO_SENTRY_TRACES_SAMPLE_RATE` | Sentry trace sample rate, Set 1.0 to capture 100% of transactions | 1.0 | -| `DJANGO_SENTRY_SEND_DEFAULT_PII` | Associate users to errors in Sentry | True | -| `GUNICORN_WORKERS` | Number of worker processes to use | 8 | -| `GUNICORN_CMD_ARGS` | [Additional args for gunicorn](https://docs.gunicorn.org/en/latest/configure.html) | not set | -| `STATIC_ROOT` | Path to the static files. In prod, you may want to mount a docker volume here, so it can be served by nginx | `/app/static` | -| `STATIC_URL` | URL path to the static files on the web server. Configure nginx to point this to `$STATIC_ROOT` | `/static` | +| Parameter | Description | Default | +|----------------------------------------------|-------------------------------------------------------------------------------------------------------|--------------------------------------------------------------| +| `DJANGO_ENV_FILE` | Path to setup environment vars in a file | .env | +| `DJANGO_DEBUG` | Boolean that turns on/off debug mode | False | +| `DJANGO_SECRET_KEY` | Secret key for cryptographic signing | not set (required) | +| `DJANGO_ALLOWED_HOSTS` | List of hosts representing the host/domain names | not set (required) | +| `DJANGO_HOST_PROTOCOL` | Protocol host is running on (http or https) | http | +| `DJANGO_HOST_DOMAIN` | Main host name server is reachable on | not set (required) | +| `DJANGO_DATABASE_NAME` | Database name | timed | +| `DJANGO_DATABASE_USER` | Database username | timed | +| `DJANGO_DATABASE_HOST` | Database hostname | localhost | +| `DJANGO_DATABASE_PORT` | Database port | 5432 | +| `DJANGO_OIDC_DEFAULT_BASE_URL` | Base URL of the OIDC provider | http://timed.local/auth/realms/timed/protocol/openid-connect | +| `DJANGO_OIDC_OP_AUTHORIZATION_ENDPOINT` | OIDC /auth endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/auth | +| `DJANGO_OIDC_OP_TOKEN_ENDPOINT` | OIDC /token endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token | +| `DJANGO_OIDC_OP_USER_ENDPOINT` | OIDC /userinfo endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/userinfo | +| `DJANGO_OIDC_OP_JWKS_ENDPOINT` | OIDC /certs endpoint | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/certs | +| `DJANGO_OIDC_RP_CLIENT_ID` | Client ID by your OIDC provider | timed-public | +| `DJANGO_OIDC_RP_CLIENT_SECRET` | Client secret by your OIDC provider, should be None (flow start is handled by frontend) | not set | +| `DJANGO_OIDC_RP_SIGN_ALGO` | Algorithm the OIDC provider uses to sign ID tokens | RS256 | +| `DJANGO_OIDC_VERIFY_SSL` | Verify SSL on OIDC request | dev: False, prod: True | +| `DJANGO_OIDC_CREATE_USER` | Create new user if it doesn't exist in the database | False | +| `DJANGO_OIDC_USERNAME_CLAIM` | Username token claim for user lookup / creation | sub | +| `DJANGO_OIDC_EMAIL_CLAIM` | Email token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | email | +| `DJANGO_OIDC_FIRSTNAME_CLAIM` | First name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | given_name | +| `DJANGO_OIDC_LASTNAME_CLAIM` | Last name token claim for creating new users (if `DJANGO_OIDC_CREATE_USER` is enabled) | family_name | +| `DJANGO_OIDC_BEARER_TOKEN_REVALIDATION_TIME` | Time (in seconds) to cache a bearer token before revalidation is needed | 60 | +| `DJANGO_OIDC_CHECK_INTROSPECT` | Use token introspection for confidential clients | True | +| `DJANGO_OIDC_OP_INTROSPECT_ENDPOINT` | OIDC token introspection endpoint (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) | {`DJANGO_OIDC_DEFAULT_BASE_URL`}/token/introspect | +| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_ID` | OIDC client id (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | timed-confidential | +| `DJANGO_OIDC_RP_INTROSPECT_CLIENT_SECRET` | OIDC client secret (if `DJANGO_OIDC_CHECK_INTROSPECT` is enabled) of confidential client | not set | +| `DJANGO_OIDC_ADMIN_LOGIN_REDIRECT_URL` | URL of the django-admin, to which the user is redirected after successful admin login | dev: http://timed.local/admin/, prod: not set | +| `DJANGO_ALLOW_LOCAL_LOGIN` | Enable / Disable login with local user/password (in admin) | True | +| `EMAIL_URL` | Uri of email server | smtp://localhost:25 | +| `DJANGO_DEFAULT_FROM_EMAIL` | Default email address to use for various responses | webmaster@localhost | +| `DJANGO_SERVER_EMAIL` | Email address error messages are sent from | root@localhost | +| `DJANGO_ADMINS` | List of people who get error notifications | not set | +| `DJANGO_WORK_REPORT_PATH` | Path of custom work report template | not set | +| `DJANGO_SENTRY_DSN` | Sentry DSN for error reporting | not set, set to enable Sentry integration | +| `DJANGO_SENTRY_TRACES_SAMPLE_RATE` | Sentry trace sample rate, Set 1.0 to capture 100% of transactions | 1.0 | +| `DJANGO_SENTRY_SEND_DEFAULT_PII` | Associate users to errors in Sentry | True | +| `HURRICANE_REQ_QUEUE_LEN` | Django Hurricane's request queue length. When full, the readiness probe toggles | 250 | +| `STATIC_ROOT` | Path to the static files. In prod, you may want to mount a docker volume here, so it can be served by nginx | `/app/static` | +| `STATIC_URL` | URL path to the static files on the web server. Configure nginx to point this to `$STATIC_ROOT` | `/static` | ## Contributing diff --git a/cmd.sh b/cmd.sh old mode 100755 new mode 100644 index e670a0609..73f54136c --- a/cmd.sh +++ b/cmd.sh @@ -1,9 +1,13 @@ #!/bin/sh -GUNICORN_WORKERS="${GUNICORN_WORKERS:-8}" +# All parameters to the script are appended as arguments to `manage.py serve` -poetry run python manage.py collectstatic --noinput +set -x -wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -- \ - poetry run python manage.py migrate --no-input && \ - poetry run gunicorn --workers=$GUNICORN_WORKERS --bind=0.0.0.0:80 timed.wsgi:application +poetry run python manage.py manage.py collectstatic --noinput + +set -e + +wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" +poetry run python manage.py migrate --no-input +poetry run python manage.py serve --static --port 80 --req-queue-len "${HURRICANE_REQ_QUEUE_LEN:-250}" "$@" diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 59718ff9d..583ee93dc 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -14,7 +14,9 @@ services: - DJANGO_OIDC_USERNAME_CLAIM=preferred_username volumes: - ./:/app - command: /bin/sh -c "wait-for-it.sh -t 60 db:5432 -- poetry run python manage.py migrate && poetry run python manage.py runserver 0.0.0.0:80" + command: /bin/sh cmd.sh --autoreload --static + ports: + - "81:81" networks: - timed.local diff --git a/poetry.lock b/poetry.lock index bc0c8de31..9fbdb2b26 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8,18 +8,18 @@ python-versions = "*" [[package]] name = "asgiref" -version = "3.5.2" +version = "3.4.1" description = "ASGI specs, helper code, and adapters" category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "asttokens" -version = "2.0.8" +version = "2.1.0" description = "Annotate AST trees with source code positions" category = "dev" optional = false @@ -55,7 +55,7 @@ tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy [[package]] name = "babel" -version = "2.10.3" +version = "2.11.0" description = "Internationalization utilities" category = "main" optional = false @@ -167,7 +167,7 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "38.0.1" +version = "38.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false @@ -273,6 +273,25 @@ python-versions = ">=3.6" [package.dependencies] Django = ">=2.2" +[[package]] +name = "django-hurricane" +version = "1.3.4" +description = "Hurricane is an initiative to fit Django perfectly with Kubernetes." +category = "main" +optional = false +python-versions = "~=3.8" + +[package.dependencies] +asgiref = ">=3.4.0,<3.5.0" +Django = ">=2.2" +pika = ">=1.1.0,<1.2.0" +requests = ">=2.25,<3.0" +tornado = ">=6.1,<7.0" + +[package.extras] +debug = ["debugpy (>=1.5,<2.0)"] +pycharm = ["pydevd-pycharm (>=213.5605.23,<213.5606.0)"] + [[package]] name = "django-money" version = "2.1.1" @@ -360,7 +379,7 @@ python-versions = ">=3.6" [[package]] name = "executing" -version = "1.1.1" +version = "1.2.0" description = "Get the currently executing AST node of a frame, and other information" category = "dev" optional = false @@ -386,7 +405,7 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "15.1.1" +version = "15.3.1" description = "Faker is a Python package that generates fake data for you." category = "dev" optional = false @@ -513,23 +532,6 @@ python-versions = ">=3.6" [package.dependencies] python-dateutil = ">=2.7" -[[package]] -name = "gunicorn" -version = "20.1.0" -description = "WSGI HTTP Server for UNIX" -category = "main" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -setuptools = ">=3.0" - -[package.extras] -eventlet = ["eventlet (>=0.24.1)"] -gevent = ["gevent (>=1.4.0)"] -setproctitle = ["setproctitle"] -tornado = ["tornado (>=0.2)"] - [[package]] name = "idna" version = "3.4" @@ -586,7 +588,7 @@ toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} [[package]] name = "ipython" -version = "8.5.0" +version = "8.6.0" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -607,9 +609,9 @@ stack-data = "*" traitlets = ">=5" [package.extras] -all = ["Sphinx (>=1.3)", "black", "curio", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.19)", "pandas", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "testpath", "trio"] +all = ["black", "curio", "docrepr", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.20)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] black = ["black"] -doc = ["Sphinx (>=1.3)"] +doc = ["docrepr", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] kernel = ["ipykernel"] nbconvert = ["nbconvert"] nbformat = ["nbformat"] @@ -617,7 +619,7 @@ notebook = ["ipywidgets", "notebook"] parallel = ["ipyparallel"] qtconsole = ["qtconsole"] test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] -test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.19)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.20)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] [[package]] name = "isort" @@ -807,17 +809,29 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "pika" +version = "1.1.0" +description = "Pika Python AMQP Client Library" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +tornado = ["tornado"] +twisted = ["twisted"] + [[package]] name = "platformdirs" -version = "2.5.2" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "2.5.3" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] +test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -844,7 +858,7 @@ twisted = ["twisted"] [[package]] name = "prompt-toolkit" -version = "3.0.31" +version = "3.0.32" description = "Library for building powerful interactive command lines in Python" category = "dev" optional = false @@ -1212,7 +1226,7 @@ requests = ">=2.23.0" [[package]] name = "pytz" -version = "2022.5" +version = "2022.6" description = "World timezone definitions, modern and historical" category = "main" optional = false @@ -1286,7 +1300,7 @@ tornado = ["tornado (>=5)"] [[package]] name = "setuptools" -version = "65.5.0" +version = "65.5.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "main" optional = false @@ -1294,7 +1308,7 @@ python-versions = ">=3.7" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -1341,15 +1355,15 @@ python-versions = ">=3.5" [[package]] name = "stack-data" -version = "0.5.1" +version = "0.6.0" description = "Extract data from python stack frames and tracebacks for informative displays" category = "dev" optional = false python-versions = "*" [package.dependencies] -asttokens = "*" -executing = "*" +asttokens = ">=2.1.0" +executing = ">=1.2.0" pure-eval = "*" [package.extras] @@ -1357,7 +1371,7 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] [[package]] name = "termcolor" -version = "2.0.1" +version = "2.1.0" description = "ANSI color formatting for output in terminal" category = "dev" optional = false @@ -1403,6 +1417,14 @@ category = "dev" optional = false python-versions = ">=3.7" +[[package]] +name = "tornado" +version = "6.2" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +category = "main" +optional = false +python-versions = ">= 3.7" + [[package]] name = "traitlets" version = "5.5.0" @@ -1494,7 +1516,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "c4eacd37040bc73ad7a98a03d130028fd93ee9d95cd07289d1d38448a9a25851" +content-hash = "7968ad9d51a0ce628f11d15648aa03c0fc3eff977743720cdfbb216efb830250" [metadata.files] appnope = [ @@ -1502,12 +1524,12 @@ appnope = [ {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, ] asgiref = [ - {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, - {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, + {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, + {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, ] asttokens = [ - {file = "asttokens-2.0.8-py2.py3-none-any.whl", hash = "sha256:e3305297c744ae53ffa032c45dc347286165e4ffce6875dc662b205db0623d86"}, - {file = "asttokens-2.0.8.tar.gz", hash = "sha256:c61e16246ecfb2cde2958406b4c8ebc043c9e6d73aaa83c941673b35e5d3a76b"}, + {file = "asttokens-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b28ed85e254b724439afc783d4bee767f780b936c3fe8b3275332f42cf5f561"}, + {file = "asttokens-2.1.0.tar.gz", hash = "sha256:4aa76401a151c8cc572d906aad7aea2a841780834a19d780f4321c0fe1b54635"}, ] atomicwrites = [ {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, @@ -1517,8 +1539,8 @@ attrs = [ {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] babel = [ - {file = "Babel-2.10.3-py3-none-any.whl", hash = "sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb"}, - {file = "Babel-2.10.3.tar.gz", hash = "sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51"}, + {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, + {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, ] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, @@ -1679,32 +1701,32 @@ coverage = [ {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, ] cryptography = [ - {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:10d1f29d6292fc95acb597bacefd5b9e812099d75a6469004fd38ba5471a977f"}, - {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3fc26e22840b77326a764ceb5f02ca2d342305fba08f002a8c1f139540cdfaad"}, - {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b72c360427889b40f36dc214630e688c2fe03e16c162ef0aa41da7ab1455153"}, - {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:194044c6b89a2f9f169df475cc167f6157eb9151cc69af8a2a163481d45cc407"}, - {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca9f6784ea96b55ff41708b92c3f6aeaebde4c560308e5fbbd3173fbc466e94e"}, - {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:16fa61e7481f4b77ef53991075de29fc5bacb582a1244046d2e8b4bb72ef66d0"}, - {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d4ef6cc305394ed669d4d9eebf10d3a101059bdcf2669c366ec1d14e4fb227bd"}, - {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3261725c0ef84e7592597606f6583385fed2a5ec3909f43bc475ade9729a41d6"}, - {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0297ffc478bdd237f5ca3a7dc96fc0d315670bfa099c04dc3a4a2172008a405a"}, - {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89ed49784ba88c221756ff4d4755dbc03b3c8d2c5103f6d6b4f83a0fb1e85294"}, - {file = "cryptography-38.0.1-cp36-abi3-win32.whl", hash = "sha256:ac7e48f7e7261207d750fa7e55eac2d45f720027d5703cd9007e9b37bbb59ac0"}, - {file = "cryptography-38.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ad7353f6ddf285aeadfaf79e5a6829110106ff8189391704c1d8801aa0bae45a"}, - {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:896dd3a66959d3a5ddcfc140a53391f69ff1e8f25d93f0e2e7830c6de90ceb9d"}, - {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d3971e2749a723e9084dd507584e2a2761f78ad2c638aa31e80bc7a15c9db4f9"}, - {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:79473cf8a5cbc471979bd9378c9f425384980fcf2ab6534b18ed7d0d9843987d"}, - {file = "cryptography-38.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:d9e69ae01f99abe6ad646947bba8941e896cb3aa805be2597a0400e0764b5818"}, - {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5067ee7f2bce36b11d0e334abcd1ccf8c541fc0bbdaf57cdd511fdee53e879b6"}, - {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:3e3a2599e640927089f932295a9a247fc40a5bdf69b0484532f530471a382750"}, - {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2e5856248a416767322c8668ef1845ad46ee62629266f84a8f007a317141013"}, - {file = "cryptography-38.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:64760ba5331e3f1794d0bcaabc0d0c39e8c60bf67d09c93dc0e54189dfd7cfe5"}, - {file = "cryptography-38.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b6c9b706316d7b5a137c35e14f4103e2115b088c412140fdbd5f87c73284df61"}, - {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0163a849b6f315bf52815e238bc2b2346604413fa7c1601eea84bcddb5fb9ac"}, - {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d1a5bd52d684e49a36582193e0b89ff267704cd4025abefb9e26803adeb3e5fb"}, - {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:765fa194a0f3372d83005ab83ab35d7c5526c4e22951e46059b8ac678b44fa5a"}, - {file = "cryptography-38.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b"}, - {file = "cryptography-38.0.1.tar.gz", hash = "sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7"}, + {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:984fe150f350a3c91e84de405fe49e688aa6092b3525f407a18b9646f6612320"}, + {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:ed7b00096790213e09eb11c97cc6e2b757f15f3d2f85833cd2d3ec3fe37c1722"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bbf203f1a814007ce24bd4d51362991d5cb90ba0c177a9c08825f2cc304d871f"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554bec92ee7d1e9d10ded2f7e92a5d70c1f74ba9524947c0ba0c850c7b011828"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b52c9e5f8aa2b802d48bd693190341fae201ea51c7a167d69fc48b60e8a959"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:728f2694fa743a996d7784a6194da430f197d5c58e2f4e278612b359f455e4a2"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfb4f4dd568de1b6af9f4cda334adf7d72cf5bc052516e1b2608b683375dd95c"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5419a127426084933076132d317911e3c6eb77568a1ce23c3ac1e12d111e61e0"}, + {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9b24bcff7853ed18a63cfb0c2b008936a9554af24af2fb146e16d8e1aed75748"}, + {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:25c1d1f19729fb09d42e06b4bf9895212292cb27bb50229f5aa64d039ab29146"}, + {file = "cryptography-38.0.3-cp36-abi3-win32.whl", hash = "sha256:7f836217000342d448e1c9a342e9163149e45d5b5eca76a30e84503a5a96cab0"}, + {file = "cryptography-38.0.3-cp36-abi3-win_amd64.whl", hash = "sha256:c46837ea467ed1efea562bbeb543994c2d1f6e800785bd5a2c98bc096f5cb220"}, + {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06fc3cc7b6f6cca87bd56ec80a580c88f1da5306f505876a71c8cfa7050257dd"}, + {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:65535bc550b70bd6271984d9863a37741352b4aad6fb1b3344a54e6950249b55"}, + {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5e89468fbd2fcd733b5899333bc54d0d06c80e04cd23d8c6f3e0542358c6060b"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6ab9516b85bebe7aa83f309bacc5f44a61eeb90d0b4ec125d2d003ce41932d36"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068147f32fa662c81aebab95c74679b401b12b57494872886eb5c1139250ec5d"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:402852a0aea73833d982cabb6d0c3bb582c15483d29fb7085ef2c42bfa7e38d7"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b1b35d9d3a65542ed2e9d90115dfd16bbc027b3f07ee3304fc83580f26e43249"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6addc3b6d593cd980989261dc1cce38263c76954d758c3c94de51f1e010c9a50"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:be243c7e2bfcf6cc4cb350c0d5cdf15ca6383bbcb2a8ef51d3c9411a9d4386f0"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cf5eefac2b52c10398a42765bfa981ce2372cbc0457e6bf9658f41ec3c41d8"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4e269dcd9b102c5a3d72be3c45d8ce20377b8076a43cbed6f660a1afe365e436"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8d41a46251bf0634e21fac50ffd643216ccecfaf3701a063257fe0b2be1b6548"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:785e4056b5a8b28f05a533fab69febf5004458e20dad7e2e13a3120d8ecec75a"}, + {file = "cryptography-38.0.3.tar.gz", hash = "sha256:bfbe6ee19615b07a98b1d2287d6a6073f734735b49ee45b11324d85efc4d5cbd"}, ] decorator = [ {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, @@ -1734,6 +1756,10 @@ django-filter = [ {file = "django-filter-21.1.tar.gz", hash = "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e"}, {file = "django_filter-21.1-py3-none-any.whl", hash = "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"}, ] +django-hurricane = [ + {file = "django-hurricane-1.3.4.tar.gz", hash = "sha256:16fd74239adc8bba75b988859a3a28820e93f8c1232c65f251e70b790de04e92"}, + {file = "django_hurricane-1.3.4-py3-none-any.whl", hash = "sha256:6706dc95b05d07e4eb32b08b6b7f11ea0fbd3952bb915f0e3648e1df3fa599a2"}, +] django-money = [ {file = "django-money-2.1.1.tar.gz", hash = "sha256:4d06041fac5c565ad049a7f8fb4bc33de5c68047b0693efa18a9931cebffb606"}, {file = "django_money-2.1.1-py3-none-any.whl", hash = "sha256:60a605d5b999e1756a18008dd7e0c5a860fd64c018a140c7a7675a4209ec3782"}, @@ -1763,16 +1789,16 @@ et-xmlfile = [ {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, ] executing = [ - {file = "executing-1.1.1-py2.py3-none-any.whl", hash = "sha256:236ea5f059a38781714a8bfba46a70fad3479c2f552abee3bbafadc57ed111b8"}, - {file = "executing-1.1.1.tar.gz", hash = "sha256:b0d7f8dcc2bac47ce6e39374397e7acecea6fdc380a6d5323e26185d70f38ea8"}, + {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"}, + {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"}, ] factory-boy = [ {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, ] faker = [ - {file = "Faker-15.1.1-py3-none-any.whl", hash = "sha256:096c15e136adb365db24d8c3964fe26bfc68fe060c9385071a339f8c14e09c8a"}, - {file = "Faker-15.1.1.tar.gz", hash = "sha256:a741b77f484215c3aab2604100669657189548f440fcb2ed0f8b7ee21c385629"}, + {file = "Faker-15.3.1-py3-none-any.whl", hash = "sha256:4a3465624515a6807e8aa7e8eeb85bdd86a2fa53de4e258892dd6be95362462e"}, + {file = "Faker-15.3.1.tar.gz", hash = "sha256:b9dd2fd9a9ac68a4e0c5040cd9e9bfaa099fa8dd15bae5f01f224a45431818d5"}, ] fancycompleter = [ {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, @@ -1813,10 +1839,6 @@ freezegun = [ {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, ] -gunicorn = [ - {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, - {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, -] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, @@ -1837,8 +1859,8 @@ ipdb = [ {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, ] ipython = [ - {file = "ipython-8.5.0-py3-none-any.whl", hash = "sha256:6f090e29ab8ef8643e521763a4f1f39dc3914db643122b1e9d3328ff2e43ada2"}, - {file = "ipython-8.5.0.tar.gz", hash = "sha256:097bdf5cd87576fd066179c9f7f208004f7a6864ee1b20f37d346c0bcb099f84"}, + {file = "ipython-8.6.0-py3-none-any.whl", hash = "sha256:91ef03016bcf72dd17190f863476e7c799c6126ec7e8be97719d1bc9a78a59a4"}, + {file = "ipython-8.6.0.tar.gz", hash = "sha256:7c959e3dedbf7ed81f9b9d8833df252c430610e2a4a6464ec13cd20975ce20a5"}, ] isort = [ {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, @@ -1972,9 +1994,13 @@ pickleshare = [ {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] +pika = [ + {file = "pika-1.1.0-py2.py3-none-any.whl", hash = "sha256:4e1a1a6585a41b2341992ec32aadb7a919d649eb82904fd8e4a4e0871c8cf3af"}, + {file = "pika-1.1.0.tar.gz", hash = "sha256:9fa76ba4b65034b878b2b8de90ff8660a59d925b087c5bb88f8fdbb4b64a1dbf"}, +] platformdirs = [ - {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, - {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, + {file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, + {file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1985,8 +2011,8 @@ prometheus-client = [ {file = "prometheus_client-0.15.0.tar.gz", hash = "sha256:be26aa452490cfcf6da953f9436e95a9f2b4d578ca80094b4458930e5f584ab1"}, ] prompt-toolkit = [ - {file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"}, - {file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"}, + {file = "prompt_toolkit-3.0.32-py3-none-any.whl", hash = "sha256:24becda58d49ceac4dc26232eb179ef2b21f133fecda7eed6018d341766ed76e"}, + {file = "prompt_toolkit-3.0.32.tar.gz", hash = "sha256:e7f2129cba4ff3b3656bbdda0e74ee00d2f874a8bcdb9dd16f5fec7b3e173cae"}, ] psycopg2-binary = [ {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, @@ -2012,6 +2038,8 @@ psycopg2-binary = [ {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"}, {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"}, {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"}, {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"}, {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"}, {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, @@ -2173,8 +2201,8 @@ python-redmine = [ {file = "python_redmine-2.3.0-py2.py3-none-any.whl", hash = "sha256:502680473a3f9b7a001f788969c62081023afa83d896c0a54963aed3cf198b89"}, ] pytz = [ - {file = "pytz-2022.5-py2.py3-none-any.whl", hash = "sha256:335ab46900b1465e714b4fda4963d87363264eb662aab5e65da039c25f1f5b22"}, - {file = "pytz-2022.5.tar.gz", hash = "sha256:c4d88f472f54d615e9cd582a5004d1e5f624854a6a27a6211591c251f22a6914"}, + {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, + {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, ] requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, @@ -2189,8 +2217,8 @@ sentry-sdk = [ {file = "sentry_sdk-1.10.1-py2.py3-none-any.whl", hash = "sha256:06c0fa9ccfdc80d7e3b5d2021978d6eb9351fa49db9b5847cf4d1f2a473414ad"}, ] setuptools = [ - {file = "setuptools-65.5.0-py3-none-any.whl", hash = "sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"}, - {file = "setuptools-65.5.0.tar.gz", hash = "sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17"}, + {file = "setuptools-65.5.1-py3-none-any.whl", hash = "sha256:d0b9a8433464d5800cbe05094acf5c6d52a91bfac9b52bcfc4d41382be5d5d31"}, + {file = "setuptools-65.5.1.tar.gz", hash = "sha256:e197a19aa8ec9722928f2206f8de752def0e4c9fc6953527360d1c36d94ddb2f"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -2209,12 +2237,12 @@ sqlparse = [ {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, ] stack-data = [ - {file = "stack_data-0.5.1-py3-none-any.whl", hash = "sha256:5120731a18ba4c82cefcf84a945f6f3e62319ef413bfc210e32aca3a69310ba2"}, - {file = "stack_data-0.5.1.tar.gz", hash = "sha256:95eb784942e861a3d80efd549ff9af6cf847d88343a12eb681d7157cfcb6e32b"}, + {file = "stack_data-0.6.0-py3-none-any.whl", hash = "sha256:b92d206ef355a367d14316b786ab41cb99eb453a21f2cb216a4204625ff7bc07"}, + {file = "stack_data-0.6.0.tar.gz", hash = "sha256:8e515439f818efaa251036af72d89e4026e2b03993f3453c000b200fb4f2d6aa"}, ] termcolor = [ - {file = "termcolor-2.0.1-py3-none-any.whl", hash = "sha256:7e597f9de8e001a3208c4132938597413b9da45382b6f1d150cff8d062b7aaa3"}, - {file = "termcolor-2.0.1.tar.gz", hash = "sha256:6b2cf769e93364a2676e1de56a7c0cff2cf5bd07f37e9cc80b0dd6320ebfe388"}, + {file = "termcolor-2.1.0-py3-none-any.whl", hash = "sha256:91dd04fdf661b89d7169cefd35f609b19ca931eb033687eaa647cef1ff177c49"}, + {file = "termcolor-2.1.0.tar.gz", hash = "sha256:b80df54667ce4f48c03fe35df194f052dc27a541ebbf2544e4d6b47b5d6949c4"}, ] testfixtures = [ {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"}, @@ -2232,6 +2260,19 @@ tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +tornado = [ + {file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"}, + {file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a2f5999215a3a06a4fc218026cd84c61b8b2b40ac5296a6db1f1451ef04c1e"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"}, + {file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"}, + {file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"}, + {file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"}, +] traitlets = [ {file = "traitlets-5.5.0-py3-none-any.whl", hash = "sha256:1201b2c9f76097195989cdf7f65db9897593b0dfd69e4ac96016661bb6f0d30f"}, {file = "traitlets-5.5.0.tar.gz", hash = "sha256:b122f9ff2f2f6c1709dab289a05555be011c87828e911c0cf4074b85cb780a79"}, diff --git a/pyproject.toml b/pyproject.toml index 1a6f43a74..ca2169453 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,8 @@ django-environ = "^0.8.1" django-money = "^2.1.1" python-redmine = "^2.3.0" sentry-sdk = "^1.9.5" -gunicorn = "^20.1.0" whitenoise = "^6.2.0" +django-hurricane = "^1.3.4" [tool.poetry.dev-dependencies] black = "22.3.0" diff --git a/timed/settings.py b/timed/settings.py index ebf9fe0a5..f962b4782 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -59,6 +59,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "hurricane", "rest_framework", "django_filters", "djmoney.apps.MoneyConfig", @@ -349,6 +350,23 @@ def parse_admins(admins): default=["task", "comment", "not_billable"], ) +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": {"console": {"class": "logging.StreamHandler"}}, + "loggers": { + "django": { + "handlers": ["console"], + "level": env.str("DJANGO_LOG_LEVEL", "INFO"), + }, + "hurricane": { + "handlers": ["console"], + "level": os.getenv("HURRICANE_LOG_LEVEL", "INFO"), + "propagate": False, + }, + }, +} + # Sentry error tracking if env.str("DJANGO_SENTRY_DSN", default=""): # pragma: no cover sentry_sdk.init( diff --git a/uwsgi.ini b/uwsgi.ini deleted file mode 100644 index f12bbe7e5..000000000 --- a/uwsgi.ini +++ /dev/null @@ -1,9 +0,0 @@ -[uwsgi] -http = 0.0.0.0:80 -wsgi-file = /app/timed/wsgi.py -max-requests = 2000 -harakiri = 5 -processes = 4 -master = True -static-map = /static/=/var/www/static -buffer-size = 32768 From 34f27517c896577ddca3e1355cdc3ba5b8233d29 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 18 Oct 2022 12:58:59 +0200 Subject: [PATCH 915/980] fix(container): executable bit for cmd.sh --- cmd.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 cmd.sh diff --git a/cmd.sh b/cmd.sh old mode 100644 new mode 100755 From 3efc77dbe6d92b3788b4da3211e433635c9e0fcd Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 9 Nov 2022 13:43:55 +0100 Subject: [PATCH 916/980] chore: prevent poetry from creating virtual environment --- Dockerfile | 3 ++- cmd.sh | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index e6fc128af..15d492723 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,8 @@ RUN pip install -U poetry ARG INSTALL_DEV_DEPENDENCIES=false COPY pyproject.toml poetry.lock /app/ -RUN if [ "$INSTALL_DEV_DEPENDENCIES" = "true" ]; then poetry install; else poetry install --no-dev; fi +RUN poetry config virtualenvs.create false \ + && if [ "$INSTALL_DEV_DEPENDENCIES" = "true" ]; then poetry install; else poetry install --no-dev; fi COPY . /app diff --git a/cmd.sh b/cmd.sh index 73f54136c..2f0b2bb78 100755 --- a/cmd.sh +++ b/cmd.sh @@ -4,10 +4,10 @@ set -x -poetry run python manage.py manage.py collectstatic --noinput +./manage.py manage.py collectstatic --noinput set -e wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" -poetry run python manage.py migrate --no-input -poetry run python manage.py serve --static --port 80 --req-queue-len "${HURRICANE_REQ_QUEUE_LEN:-250}" "$@" +./manage.py migrate --no-input +./manage.py serve --static --port 80 --req-queue-len "${HURRICANE_REQ_QUEUE_LEN:-250}" "$@" From 31edbc9e7e62a5d93c82d56db83f173a931d36aa Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 9 Nov 2022 13:43:55 +0100 Subject: [PATCH 917/980] chore: remove poetry prefix from commands --- .github/workflows/test.yml | 8 ++++---- CONTRIBUTING.md | 8 ++++---- Makefile | 16 ++++++++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6938ae01e..c8e507e6d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,8 +23,8 @@ jobs: docker-compose up -d --build backend - name: Lint the code run: | - docker-compose exec -T backend poetry run black --check . - docker-compose exec -T backend poetry run flake8 - docker-compose exec -T backend poetry run python manage.py makemigrations --check --dry-run --no-input + docker-compose exec -T backend black --check . + docker-compose exec -T backend flake8 + docker-compose exec -T backend python manage.py makemigrations --check --dry-run --no-input - name: Run pytest - run: docker-compose exec -T backend poetry run pytest --no-cov-on-fail --cov --create-db -vv + run: docker-compose exec -T backend pytest --no-cov-on-fail --cov --create-db -vv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 690dd5a9d..02510312d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,13 +32,13 @@ etc. ```bash # linting -poetry run flake8 +flake8 # format code -poetry run black . +black . # running tests -poetry run pytest +pytest # create migrations -poetry run python manage.py makemigrations +python manage.py makemigrations ``` Writing of code can still happen outside the docker container of course. diff --git a/Makefile b/Makefile index 8b4edea65..7657fd5b7 100644 --- a/Makefile +++ b/Makefile @@ -14,15 +14,15 @@ stop: ## Stop the development server .PHONY: lint lint: ## Lint the project - @docker-compose exec backend sh -c "poetry run black --check . && poetry run flake8" + @docker-compose exec backend sh -c "black --check . && flake8" .PHONY: format-code format-code: ## Format the backend code - @docker-compose exec backend sh -c "poetry run black . && poetry run isort ." + @docker-compose exec backend sh -c "black . && isort ." .PHONY: test test: ## Test the project - @docker-compose exec backend sh -c "poetry run black . && poetry run isort . && poetry run pytest --no-cov-on-fail --cov + @docker-compose exec backend sh -c "black . && isort . && pytest --no-cov-on-fail --cov" .PHONY: bash bash: ## Shell into the backend @@ -34,15 +34,15 @@ dbshell: ## Start a psql shell .PHONY: shell_plus shell_plus: ## Run shell_plus - @docker-compose exec backend poetry run python manage.py shell_plus + @docker-compose exec backend ./manage.py shell_plus .PHONY: makemigrations makemigrations: ## Make django migrations - @docker-compose exec backend poetry run python manage.py makemigrations + @docker-compose exec backend ./manage.py makemigrations .PHONY: migrate migrate: ## Migrate django - @docker-compose backend poetry run python manage.py migrate + @docker-compose exec backend ./manage.py migrate .PHONY: debug-backend debug-backend: ## Start backend container with service ports for debugging @@ -50,8 +50,8 @@ debug-backend: ## Start backend container with service ports for debugging .PHONY: flush flush: ## Flush database contents - @docker-compose exec backend poetry run python manage.py flush --no-input + @docker-compose exec backend ./manage.py flush --no-input .PHONY: loaddata loaddata: flush ## Loads test data into the database - @docker-compose exec backend poetry run python manage.py loaddata timed/fixtures/test_data.json + @docker-compose exec backend ./manage.py loaddata timed/fixtures/test_data.json From 0aa9da69e8432a4d9537b65dd935bebe23fd4c72 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 8 Nov 2022 10:01:15 +0100 Subject: [PATCH 918/980] feat(redmine): update expenditures on redmine projects command to update expenditures such as amount offered, amount invoiced project estimated hours and project spent hours in redmine --- timed/projects/factories.py | 2 + .../commands/update_project_expenditure.py | 75 +++++++++++++++++++ .../tests/test_update_project_expenditure.py | 47 ++++++++++++ timed/settings.py | 4 + 4 files changed, 128 insertions(+) create mode 100644 timed/redmine/management/commands/update_project_expenditure.py create mode 100644 timed/redmine/tests/test_update_project_expenditure.py diff --git a/timed/projects/factories.py b/timed/projects/factories.py index 3da958e4a..2febbb3ac 100644 --- a/timed/projects/factories.py +++ b/timed/projects/factories.py @@ -50,6 +50,8 @@ class ProjectFactory(DjangoModelFactory): cost_center = SubFactory("timed.projects.factories.CostCenterFactory") billing_type = SubFactory("timed.projects.factories.BillingTypeFactory") remaining_effort_tracking = False + amount_offered = Faker("pydecimal", positive=True, left_digits=4, right_digits=2) + amount_invoiced = Faker("pydecimal", positive=True, left_digits=4, right_digits=2) class Meta: """Meta informations for the project factory.""" diff --git a/timed/redmine/management/commands/update_project_expenditure.py b/timed/redmine/management/commands/update_project_expenditure.py new file mode 100644 index 000000000..b7f426089 --- /dev/null +++ b/timed/redmine/management/commands/update_project_expenditure.py @@ -0,0 +1,75 @@ +import redminelib +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import Case, Count, Sum, When + +from timed.projects.models import Project + + +class Command(BaseCommand): + help = "Update expenditures on associated Redmine projects." + + def handle(self, *args, **options): + redmine = redminelib.Redmine( + settings.REDMINE_URL, + key=settings.REDMINE_APIKEY, + ) + + affected_projects = ( + Project.objects.filter( + archived=False, + redmine_project__isnull=False, + ) + .annotate(count_reports=Count("tasks__reports")) + .annotate( + total_hours=Case( + When(count_reports__gt=0, then=Sum("tasks__reports__duration")), + default=None, + ) + ) + ) + + for project in affected_projects.iterator(): + estimated_hours = ( + project.estimated_time.total_seconds() / 3600 + if project.estimated_time + else 0.0 + ) + total_spent_hours = ( + project.total_hours.total_seconds() / 3600 + if project.total_hours + else 0.0 + ) + try: + issue = redmine.issue.get(project.redmine_project.issue_id) + except redminelib.exceptions.BaseRedmineError as e: + self.stdout.write( + self.style.ERROR( + f"Failed retrieving Project {project.name} with Redmine issue {project.redmine_project.issue_id} assigned. Skipping.\n{e}" + ) + ) + continue + issue.estimated_hours = estimated_hours + # fields not active in Redmine projects settings won't be saved + issue.custom_fields = [ + { + "id": settings.REDMINE_SPENTHOURS_FIELD, + "value": total_spent_hours, + }, + { + "id": settings.REDMINE_AMOUNT_OFFERED_FIELD, + "value": project.amount_offered.amount, + }, + { + "id": settings.REDMINE_AMOUNT_INVOICED_FIELD, + "value": project.amount_invoiced.amount, + }, + ] + try: + issue.save() + except redminelib.exceptions.BaseRedmineError as e: # pragma: no cover + self.stdout.write( + self.style.ERROR( + f"Failed to save Project {project.name} with Redmine issue {issue.id}!\n{e}" + ) + ) diff --git a/timed/redmine/tests/test_update_project_expenditure.py b/timed/redmine/tests/test_update_project_expenditure.py new file mode 100644 index 000000000..7fb8eb9f0 --- /dev/null +++ b/timed/redmine/tests/test_update_project_expenditure.py @@ -0,0 +1,47 @@ +import datetime + +from django.core.management import call_command +from redminelib.exceptions import ResourceNotFoundError + +from timed.redmine.models import RedmineProject + + +def test_update_project_expenditure(db, mocker, report_factory): + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + report = report_factory(duration=datetime.timedelta(hours=4)) + project = report.task.project + project.estimated_time = datetime.timedelta(hours=10) + project.save() + + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + call_command("update_project_expenditure") + + redmine_instance.issue.get.assert_called_once_with(1000) + assert issue.estimated_hours == project.estimated_time.total_seconds() / 3600 + assert issue.custom_fields[0]["value"] == report.duration.total_seconds() / 3600 + assert issue.custom_fields[1]["value"] == project.amount_offered.amount + assert issue.custom_fields[2]["value"] == project.amount_invoiced.amount + issue.save.assert_called_once_with() + + +def test_update_project_expenditure_invalid_issue( + db, freezer, mocker, capsys, report_factory +): + redmine_instance = mocker.MagicMock() + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + redmine_instance.issue.get.side_effect = ResourceNotFoundError() + + report = report_factory(duration=datetime.timedelta(hours=4)) + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + call_command("update_project_expenditure") + + out, _ = capsys.readouterr() + assert "issue 1000 assigned. Skipping." in out diff --git a/timed/settings.py b/timed/settings.py index f962b4782..b5c94a22f 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -328,6 +328,10 @@ def parse_admins(admins): REDMINE_HTACCESS_USER = env.str("DJANGO_REDMINE_HTACCESS_USER", default="") REDMINE_HTACCESS_PASSWORD = env.str("DJANGO_REDMINE_HTACCESS_PASSWORD", default="") REDMINE_SPENTHOURS_FIELD = env.int("DJANGO_REDMINE_SPENTHOURS_FIELD", default=0) +REDMINE_AMOUNT_OFFERED_FIELD = env.int("DJANGO_REDMINE_AMOUNT_OFFERED_FIELD", default=1) +REDMINE_AMOUNT_INVOICED_FIELD = env.int( + "DJANGO_REDMINE_AMOUNT_INVOICED_FIELD", default=2 +) # Work report definition From 766f79bc17ce927a05217e9bacc18c478404e6f6 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 24 Nov 2022 14:01:33 +0100 Subject: [PATCH 919/980] feat(redmine): import project expenditure from redmine Import amount offered and amount invoiced for projects from redmine --- .../commands/import_project_data.py | 61 +++++++++++++++++++ timed/settings.py | 1 + 2 files changed, 62 insertions(+) create mode 100644 timed/redmine/management/commands/import_project_data.py diff --git a/timed/redmine/management/commands/import_project_data.py b/timed/redmine/management/commands/import_project_data.py new file mode 100644 index 000000000..3e83216e2 --- /dev/null +++ b/timed/redmine/management/commands/import_project_data.py @@ -0,0 +1,61 @@ +import redminelib +from django.conf import settings +from django.core.management.base import BaseCommand + +from timed.projects.models import Project + + +class Command(BaseCommand): # pragma: no cover + help = "Update projects" + + def handle(self, *args, **options): + redmine = redminelib.Redmine( + settings.REDMINE_URL, + key=settings.REDMINE_APIKEY, + ) + + open_redmine_projects = list( + redmine.issue.filter( + tracker_id=6, status="open", project_id=settings.REDMINE_BUILD_PROJECT + ) + ) + closed_redmine_projects = list( + redmine.issue.filter( + tracker_id=6, + status_id=5, + closed_on="y", + project_id=settings.REDMINE_BUILD_PROJECT, + ) + ) + + redmine_projects = open_redmine_projects + closed_redmine_projects + + for redmine_project in redmine_projects: + timed_project = Project.objects.filter( + redmine_project__issue_id=redmine_project.id, + ).first() + + if not timed_project: + continue + + custom_fields = list(redmine_project.custom_fields.values()) + + amount_offered = next( + item for item in custom_fields if item["name"] == "Offeriert" + ) + + amount_invoiced = next( + item for item in custom_fields if item["name"] == "Verrechnet" + ) + + timed_project.amount_offered = ( + amount_offered["value"] + if amount_offered != "" + else timed_project.amount_offered + ) + timed_project.amount_invoiced = ( + amount_invoiced["value"] + if amount_invoiced != "" + else timed_project.amount_invoiced + ) + timed_project.save() diff --git a/timed/settings.py b/timed/settings.py index b5c94a22f..1baf8e0be 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -332,6 +332,7 @@ def parse_admins(admins): REDMINE_AMOUNT_INVOICED_FIELD = env.int( "DJANGO_REDMINE_AMOUNT_INVOICED_FIELD", default=2 ) +REDMINE_BUILD_PROJECT = env.str("DJANGO_REDMINE_BUILD_PROJECT", default="build") # Work report definition From 144444b298f2139f44a8ca291ca34ccfb3f66899 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 23 Nov 2022 12:00:29 +0100 Subject: [PATCH 920/980] feat(statistics): show amount offered and invoiced in project statistics --- timed/reports/serializers.py | 5 +++++ timed/reports/tests/test_project_statistic.py | 10 ++++++++++ timed/reports/views.py | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 3088d30c8..578f708a5 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -2,6 +2,7 @@ from rest_framework_json_api import relations from rest_framework_json_api.serializers import ( CharField, + DecimalField, DurationField, IntegerField, Serializer, @@ -41,6 +42,10 @@ class Meta: class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() name = CharField() + amount_offered = DecimalField(max_digits=None, decimal_places=2) + amount_offered_currency = CharField() + amount_invoiced = DecimalField(max_digits=None, decimal_places=2) + amount_invoiced_currency = CharField() class Meta: resource_name = "project-statistics" diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index 5f4616c0b..2a8152744 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -37,8 +37,10 @@ def test_project_statistic_list( is_external=False, ) report = ReportFactory.create(duration=timedelta(hours=1)) + project = report.task.project ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) + project_2 = report2.task.project task = TaskFactory(project=report.task.project) ReportFactory.create(duration=timedelta(hours=2), task=task) @@ -56,6 +58,10 @@ def test_project_statistic_list( "attributes": { "duration": "04:00:00", "name": report2.task.project.name, + "amount-offered": str(project_2.amount_offered.amount), + "amount-offered-currency": project_2.amount_offered_currency, + "amount-invoiced": str(project_2.amount_invoiced.amount), + "amount-invoiced-currency": project_2.amount_invoiced_currency, }, }, { @@ -64,6 +70,10 @@ def test_project_statistic_list( "attributes": { "duration": "05:00:00", "name": report.task.project.name, + "amount-offered": str(project.amount_offered.amount), + "amount-offered-currency": project.amount_offered_currency, + "amount-invoiced": str(project.amount_invoiced.amount), + "amount-invoiced-currency": project.amount_invoiced_currency, }, }, ] diff --git a/timed/reports/views.py b/timed/reports/views.py index 1a555acd8..68878154e 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -112,6 +112,10 @@ def get_queryset(self): start=Project, annotations={ "name": F("name"), + "amount_offered": F("amount_offered"), + "amount_offered_currency": F("amount_offered_currency"), + "amount_invoiced": F("amount_invoiced"), + "amount_invoiced_currency": F("amount_invoiced_currency"), }, ) return queryset From b81e28e9d0b8386e54caf57b90960e392d5811c0 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 1 Sep 2022 13:33:09 +0200 Subject: [PATCH 921/980] feat(notifications): project budget check notifications Created a management command to check the current budget of a project. The new command creates a note in the corresponding Redmine issue if the budget exceeded 30% or 70%. --- timed/notifications/__init__.py | 0 timed/notifications/factories.py | 12 ++ .../management/commands/budget_check.py | 97 ++++++++++++++ .../notifications/migrations/0001_initial.py | 50 ++++++++ timed/notifications/migrations/__init__.py | 0 timed/notifications/models.py | 26 ++++ .../templates/budget_reminder.txt | 8 ++ .../notifications/tests/test_budget_check.py | 121 ++++++++++++++++++ timed/settings.py | 3 + 9 files changed, 317 insertions(+) create mode 100644 timed/notifications/__init__.py create mode 100644 timed/notifications/factories.py create mode 100644 timed/notifications/management/commands/budget_check.py create mode 100644 timed/notifications/migrations/0001_initial.py create mode 100644 timed/notifications/migrations/__init__.py create mode 100644 timed/notifications/models.py create mode 100644 timed/notifications/templates/budget_reminder.txt create mode 100644 timed/notifications/tests/test_budget_check.py diff --git a/timed/notifications/__init__.py b/timed/notifications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/notifications/factories.py b/timed/notifications/factories.py new file mode 100644 index 000000000..6cade3d2b --- /dev/null +++ b/timed/notifications/factories.py @@ -0,0 +1,12 @@ +from factory import Faker, SubFactory +from factory.django import DjangoModelFactory + +from timed.notifications.models import Notification + + +class NotificationFactory(DjangoModelFactory): + project = SubFactory("timed.projects.factories.ProjectFactory") + notification_type = Faker("word", ext_word_list=Notification.NOTIFICATION_TYPES) + + class Meta: + model = Notification diff --git a/timed/notifications/management/commands/budget_check.py b/timed/notifications/management/commands/budget_check.py new file mode 100644 index 000000000..d816b6948 --- /dev/null +++ b/timed/notifications/management/commands/budget_check.py @@ -0,0 +1,97 @@ +import redminelib +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import Sum +from django.template.loader import get_template +from django.utils.timezone import now + +from timed.notifications.models import Notification +from timed.projects.models import Project +from timed.tracking.models import Report + +template = get_template("budget_reminder.txt", using="text") + + +class Command(BaseCommand): + help = "Check budget of a project and update corresponding Redmine Project." + + def handle(self, *args, **options): + redmine = redminelib.Redmine( + settings.REDMINE_URL, + key=settings.REDMINE_APIKEY, + ) + + projects = ( + Project.objects.filter( + archived=False, + cost_center__name__contains=settings.BUILD_PROJECTS, + redmine_project__isnull=False, + estimated_time__isnull=False, + ) + .exclude(notifications__notification_type=Notification.BUDGET_CHECK_70) + .order_by("name") + ) + + for project in projects.iterator(): + billable_hours = ( + Report.objects.filter(task__project=project, not_billable=False) + .aggregate(billable_hours=Sum("duration")) + .get("billable_hours") + ).total_seconds() / 3600 + estimated_hours = project.estimated_time.total_seconds() / 3600 + + try: + budget_percentage = billable_hours / estimated_hours + except ZeroDivisionError: + self.stdout.write( + self.style.WARNING(f"Project {project.name} has no estimated time!") + ) + continue + + if budget_percentage <= 0.3: + continue + try: + issue = redmine.issue.get(project.redmine_project.issue_id) + except redminelib.exceptions.ResourceNotFoundError: + self.stdout.write( + self.style.ERROR( + f"Project {project.name} has an invalid Redmine issue {project.redmine_project.issue_id} assigned. Skipping." + ) + ) + continue + + notification, _ = Notification.objects.get_or_create( + notification_type=Notification.BUDGET_CHECK_30 + if budget_percentage <= 0.7 + else Notification.BUDGET_CHECK_70, + project=project, + ) + + if notification.sent_at: + self.stdout.write( + self.style.NOTICE( + f"Notification {notification.notification_type} for Project {project.name} already sent. Skipping." + ) + ) + continue + + issue.notes = template.render( + { + "estimated_time": estimated_hours, + "billable_hours": billable_hours, + "budget_percentage": 30 + if notification.notification_type == Notification.BUDGET_CHECK_30 + else 70, + } + ) + + try: + issue.save() + notification.sent_at = now() + notification.save() + except redminelib.exceptions.BaseRedmineError: # pragma: no cover + self.stdout.write( + self.style.ERROR( + f"Cannot reach Redmine server! Failed to save Redmine issue {issue.id} and notification {notification}" + ) + ) diff --git a/timed/notifications/migrations/0001_initial.py b/timed/notifications/migrations/0001_initial.py new file mode 100644 index 000000000..4e3001cab --- /dev/null +++ b/timed/notifications/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.16 on 2022-11-30 08:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0015_remaining_effort_task_project"), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("sent_at", models.DateTimeField(null=True)), + ( + "notification_type", + models.CharField( + choices=[ + ("budget_check_30", "project budget exceeded 30%"), + ("budget_check_70", "project budget exceeded 70%"), + ], + max_length=50, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="projects.project", + ), + ), + ], + ), + ] diff --git a/timed/notifications/migrations/__init__.py b/timed/notifications/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timed/notifications/models.py b/timed/notifications/models.py new file mode 100644 index 000000000..25819a670 --- /dev/null +++ b/timed/notifications/models.py @@ -0,0 +1,26 @@ +from django.db import models + +from timed.projects.models import Project + + +class Notification(models.Model): + BUDGET_CHECK_30 = "budget_check_30" + BUDGET_CHECK_70 = "budget_check_70" + + NOTIFICATION_TYPE_CHOICES = [ + (BUDGET_CHECK_30, "project budget exceeded 30%"), + (BUDGET_CHECK_70, "project budget exceeded 70%"), + ] + + NOTIFICATION_TYPES = [n for n, _ in NOTIFICATION_TYPE_CHOICES] + + sent_at = models.DateTimeField(null=True) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, null=True, related_name="notifications" + ) + notification_type = models.CharField( + max_length=50, choices=NOTIFICATION_TYPE_CHOICES + ) + + def __str__(self): + return f"Notification: {self.get_notification_type_display()}, id: {self.pk}" diff --git a/timed/notifications/templates/budget_reminder.txt b/timed/notifications/templates/budget_reminder.txt new file mode 100644 index 000000000..50f6be3a6 --- /dev/null +++ b/timed/notifications/templates/budget_reminder.txt @@ -0,0 +1,8 @@ +``` +## Project exceeded {{budget_percentage}}% of budget + +- Billable Hours: {{billable_hours}} +- Budget: {{estimated_time}} + +To PM: Please check the remaining effort estimate. If more budget is needed, reach out to relevant stakeholders. +``` \ No newline at end of file diff --git a/timed/notifications/tests/test_budget_check.py b/timed/notifications/tests/test_budget_check.py new file mode 100644 index 000000000..f5cd6de32 --- /dev/null +++ b/timed/notifications/tests/test_budget_check.py @@ -0,0 +1,121 @@ +import datetime + +import pytest +from django.core.management import call_command +from django.utils.timezone import now +from redminelib.exceptions import ResourceNotFoundError + +from timed.notifications.factories import NotificationFactory +from timed.notifications.models import Notification +from timed.redmine.models import RedmineProject + + +@pytest.mark.parametrize( + "duration, percentage_exceeded, notification_count", + [(1, 0, 0), (3, 0, 0), (4, 30, 1), (8, 70, 2)], +) +def test_budget_check_1( + db, mocker, report_factory, duration, percentage_exceeded, notification_count +): + """Test budget check.""" + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + report = report_factory(duration=datetime.timedelta(hours=duration)) + project = report.task.project + project.estimated_time = datetime.timedelta(hours=10) + project.save() + project.cost_center.name = "DEV_BUILD" + project.cost_center.save() + + if percentage_exceeded == 70: + NotificationFactory( + project=project, notification_type=Notification.BUDGET_CHECK_30 + ) + + report_hours = report.duration.total_seconds() / 3600 + estimated_hours = project.estimated_time.total_seconds() / 3600 + RedmineProject.objects.create(project=project, issue_id=1000) + + call_command("budget_check") + + if percentage_exceeded: + redmine_instance.issue.get.assert_called_once_with(1000) + assert f"Project exceeded {percentage_exceeded}%" in issue.notes + assert f"Billable Hours: {report_hours}" in issue.notes + assert f"Budget: {estimated_hours}\n" in issue.notes + + issue.save.assert_called_once_with() + assert Notification.objects.all().count() == notification_count + + +def test_budget_check_skip_notification(db, capsys, mocker, report_factory): + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + report = report_factory(duration=datetime.timedelta(hours=5)) + project = report.task.project + project.estimated_time = datetime.timedelta(hours=10) + project.save() + project.cost_center.name = "DEV_BUILD" + project.cost_center.save() + + notification = NotificationFactory( + project=project, notification_type=Notification.BUDGET_CHECK_30, sent_at=now() + ) + + RedmineProject.objects.create(project=project, issue_id=1000) + + call_command("budget_check") + + out, _ = capsys.readouterr() + assert ( + f"Notification {notification.notification_type} for Project {project.name} already sent. Skipping" + in out + ) + + +def test_budget_check_no_estimated_timed(db, mocker, capsys, report_factory): + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + report = report_factory() + project = report.task.project + project.estimated_time = datetime.timedelta(hours=0) + project.save() + project.cost_center.name = "DEV_BUILD" + project.cost_center.save() + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + call_command("budget_check") + + out, _ = capsys.readouterr() + assert f"Project {project.name} has no estimated time!" in out + + +def test_budget_check_invalid_issue(db, mocker, capsys, report_factory): + redmine_instance = mocker.MagicMock() + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + redmine_instance.issue.get.side_effect = ResourceNotFoundError() + + report = report_factory(duration=datetime.timedelta(hours=4)) + report.task.project.estimated_time = datetime.timedelta(hours=10) + report.task.project.save() + report.task.project.cost_center.name = "DEV_BUILD" + report.task.project.cost_center.save() + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + call_command("budget_check") + + out, _ = capsys.readouterr() + assert "issue 1000 assigned" in out diff --git a/timed/settings.py b/timed/settings.py index 1baf8e0be..e94471229 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -73,6 +73,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "timed.reports", "timed.redmine", "timed.subscription", + "timed.notifications", ] if ENV == "dev": @@ -396,3 +397,5 @@ def parse_admins(admins): ) CORS_ALLOWED_ORIGINS = env.list("DJANGO_CORS_ALLOWED_ORIGINS", default=[]) DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +BUILD_PROJECTS = env.str("DJANGO_BUILD_PROJECT", default="_BUILD") From 21e5dd7861a52793cf4b40e94c04de78a64ca3ec Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 31 Oct 2022 08:57:37 +0100 Subject: [PATCH 922/980] fix(projects): ignore signal when loading a fixture --- timed/projects/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/timed/projects/models.py b/timed/projects/models.py index 7f962ac23..a8b6acb54 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -267,6 +267,10 @@ def update_billed_flag_on_reports(sender, instance, **kwargs): The billed flag should primarily be set in frontend. This is only a quicker way for the accountants to update all reports at once. """ + # ignore signal when loading a fixture + if kwargs.get("raw", False): # pragma: no cover + return + # check whether the project was created or is being updated if instance.pk: if instance.billed != Project.objects.get(id=instance.id).billed: From 903594e874c6a11201de0b1fce1afa75c83b2e00 Mon Sep 17 00:00:00 2001 From: Wiktor Kepczynski <55092761+trowik@users.noreply.github.com> Date: Tue, 13 Dec 2022 10:33:15 +0100 Subject: [PATCH 923/980] chore(notifications): move notification commands to notifications app (#914) --- .../commands/notify_changed_employments.py | 5 +++ .../commands/notify_reviewers_unverified.py | 5 +++ .../commands/notify_supervisors_shorttime.py | 6 ++++ ...02_alter_notification_notification_type.py | 31 +++++++++++++++++++ timed/notifications/models.py | 8 +++++ .../notify_admin.py | 6 ++++ .../mail/notify_changed_employments.txt | 0 .../mail/notify_reviewers_unverified.txt | 0 .../mail/notify_supervisor_shorttime.txt | 0 .../tests/test_notify_changed_employments.py | 2 ++ .../tests/test_notify_reviewers_unverified.py | 2 ++ .../test_notify_supervisors_shorttime.py | 3 ++ timed/reports/management/__init__.py | 0 timed/reports/management/commands/__init__.py | 0 timed/subscription/tests/test_order.py | 2 ++ timed/subscription/views.py | 3 +- 16 files changed, 72 insertions(+), 1 deletion(-) rename timed/{reports => notifications}/management/commands/notify_changed_employments.py (89%) rename timed/{reports => notifications}/management/commands/notify_reviewers_unverified.py (96%) rename timed/{reports => notifications}/management/commands/notify_supervisors_shorttime.py (95%) create mode 100644 timed/notifications/migrations/0002_alter_notification_notification_type.py rename timed/{subscription => notifications}/notify_admin.py (88%) rename timed/{reports => notifications}/templates/mail/notify_changed_employments.txt (100%) rename timed/{reports => notifications}/templates/mail/notify_reviewers_unverified.txt (100%) rename timed/{reports => notifications}/templates/mail/notify_supervisor_shorttime.txt (100%) rename timed/{reports => notifications}/tests/test_notify_changed_employments.py (90%) rename timed/{reports => notifications}/tests/test_notify_reviewers_unverified.py (97%) rename timed/{reports => notifications}/tests/test_notify_supervisors_shorttime.py (92%) delete mode 100644 timed/reports/management/__init__.py delete mode 100644 timed/reports/management/commands/__init__.py diff --git a/timed/reports/management/commands/notify_changed_employments.py b/timed/notifications/management/commands/notify_changed_employments.py similarity index 89% rename from timed/reports/management/commands/notify_changed_employments.py rename to timed/notifications/management/commands/notify_changed_employments.py index 6fe8cb08f..3a57878da 100644 --- a/timed/reports/management/commands/notify_changed_employments.py +++ b/timed/notifications/management/commands/notify_changed_employments.py @@ -7,6 +7,7 @@ from django.utils import timezone from timed.employment.models import Employment +from timed.notifications.models import Notification template = get_template("mail/notify_changed_employments.txt", using="text") @@ -57,3 +58,7 @@ def handle(self, *args, **options): headers=settings.EMAIL_EXTRA_HEADERS, ) message.send() + Notification.objects.create( + notification_type=Notification.CHANGED_EMPLOYMENT, + sent_at=timezone.now(), + ) diff --git a/timed/reports/management/commands/notify_reviewers_unverified.py b/timed/notifications/management/commands/notify_reviewers_unverified.py similarity index 96% rename from timed/reports/management/commands/notify_reviewers_unverified.py rename to timed/notifications/management/commands/notify_reviewers_unverified.py index 37d584c34..a1ea535f7 100644 --- a/timed/reports/management/commands/notify_reviewers_unverified.py +++ b/timed/notifications/management/commands/notify_reviewers_unverified.py @@ -7,7 +7,9 @@ from django.core.management.base import BaseCommand from django.db.models import Q from django.template.loader import get_template +from django.utils.timezone import now +from timed.notifications.models import Notification from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee from timed.tracking.models import Report @@ -177,3 +179,6 @@ def _notify_reviewers(self, start, end, reports, optional_message, cc): messages.append(message) if len(messages) > 0: connection.send_messages(messages) + Notification.objects.create( + notification_type=Notification.REVIEWER_UNVERIFIED, sent_at=now() + ) diff --git a/timed/reports/management/commands/notify_supervisors_shorttime.py b/timed/notifications/management/commands/notify_supervisors_shorttime.py similarity index 95% rename from timed/reports/management/commands/notify_supervisors_shorttime.py rename to timed/notifications/management/commands/notify_supervisors_shorttime.py index a7da26264..1cd0d20ff 100644 --- a/timed/reports/management/commands/notify_supervisors_shorttime.py +++ b/timed/notifications/management/commands/notify_supervisors_shorttime.py @@ -5,6 +5,9 @@ from django.core.mail import EmailMessage, get_connection from django.core.management.base import BaseCommand from django.template.loader import get_template +from django.utils.timezone import now + +from timed.notifications.models import Notification template = get_template("mail/notify_supervisor_shorttime.txt", using="text") @@ -145,3 +148,6 @@ def _notify_supervisors(self, start, end, ratio, supervisees): if len(mails) > 0: connection = get_connection() connection.send_messages(mails) + Notification.objects.create( + notification_type=Notification.SUPERVISORS_SHORTTIME, sent_at=now() + ) diff --git a/timed/notifications/migrations/0002_alter_notification_notification_type.py b/timed/notifications/migrations/0002_alter_notification_notification_type.py new file mode 100644 index 000000000..159576b7e --- /dev/null +++ b/timed/notifications/migrations/0002_alter_notification_notification_type.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.16 on 2022-12-12 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="notification_type", + field=models.CharField( + choices=[ + ("budget_check_30", "project budget exceeded 30%"), + ("budget_check_70", "project budget exceeded 70%"), + ("changed_employment", "recently changed employment"), + ("reviewers_unverified", "reviewer has reports to verify"), + ( + "supervisors_shorttime", + "supervisor has supervisees with short time", + ), + ("notify_accountants", "notify accountats"), + ], + max_length=50, + ), + ), + ] diff --git a/timed/notifications/models.py b/timed/notifications/models.py index 25819a670..00339cebd 100644 --- a/timed/notifications/models.py +++ b/timed/notifications/models.py @@ -6,10 +6,18 @@ class Notification(models.Model): BUDGET_CHECK_30 = "budget_check_30" BUDGET_CHECK_70 = "budget_check_70" + CHANGED_EMPLOYMENT = "changed_employment" + REVIEWER_UNVERIFIED = "reviewers_unverified" + SUPERVISORS_SHORTTIME = "supervisors_shorttime" + NOTIFY_ACCOUNTANTS = "notify_accountants" NOTIFICATION_TYPE_CHOICES = [ (BUDGET_CHECK_30, "project budget exceeded 30%"), (BUDGET_CHECK_70, "project budget exceeded 70%"), + (CHANGED_EMPLOYMENT, "recently changed employment"), + (REVIEWER_UNVERIFIED, "reviewer has reports to verify"), + (SUPERVISORS_SHORTTIME, "supervisor has supervisees with short time"), + (NOTIFY_ACCOUNTANTS, "notify accountats"), ] NOTIFICATION_TYPES = [n for n, _ in NOTIFICATION_TYPE_CHOICES] diff --git a/timed/subscription/notify_admin.py b/timed/notifications/notify_admin.py similarity index 88% rename from timed/subscription/notify_admin.py rename to timed/notifications/notify_admin.py index f93dd21b5..e3a25b934 100644 --- a/timed/subscription/notify_admin.py +++ b/timed/notifications/notify_admin.py @@ -3,6 +3,9 @@ from django.conf import settings from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import get_template, render_to_string +from django.utils.timezone import now + +from timed.notifications.models import Notification def prepare_and_send_email(project, order_duration): @@ -54,3 +57,6 @@ def prepare_and_send_email(project, order_duration): messages.append(message) connection.send_messages(messages) + Notification.objects.create( + notification_type=Notification.REVIEWER_UNVERIFIED, sent_at=now() + ) diff --git a/timed/reports/templates/mail/notify_changed_employments.txt b/timed/notifications/templates/mail/notify_changed_employments.txt similarity index 100% rename from timed/reports/templates/mail/notify_changed_employments.txt rename to timed/notifications/templates/mail/notify_changed_employments.txt diff --git a/timed/reports/templates/mail/notify_reviewers_unverified.txt b/timed/notifications/templates/mail/notify_reviewers_unverified.txt similarity index 100% rename from timed/reports/templates/mail/notify_reviewers_unverified.txt rename to timed/notifications/templates/mail/notify_reviewers_unverified.txt diff --git a/timed/reports/templates/mail/notify_supervisor_shorttime.txt b/timed/notifications/templates/mail/notify_supervisor_shorttime.txt similarity index 100% rename from timed/reports/templates/mail/notify_supervisor_shorttime.txt rename to timed/notifications/templates/mail/notify_supervisor_shorttime.txt diff --git a/timed/reports/tests/test_notify_changed_employments.py b/timed/notifications/tests/test_notify_changed_employments.py similarity index 90% rename from timed/reports/tests/test_notify_changed_employments.py rename to timed/notifications/tests/test_notify_changed_employments.py index 16cd47c45..d901bcd1e 100644 --- a/timed/reports/tests/test_notify_changed_employments.py +++ b/timed/notifications/tests/test_notify_changed_employments.py @@ -3,6 +3,7 @@ from django.core.management import call_command from timed.employment.factories import EmploymentFactory +from timed.notifications.models import Notification def test_notify_changed_employments(db, mailoutbox, freezer): @@ -27,3 +28,4 @@ def test_notify_changed_employments(db, mailoutbox, freezer): print(mail.body) assert "80% {0}".format(finished.user.get_full_name()) in mail.body assert "None 100% {0}".format(new.user.get_full_name()) in mail.body + assert Notification.objects.all().count() == 1 diff --git a/timed/reports/tests/test_notify_reviewers_unverified.py b/timed/notifications/tests/test_notify_reviewers_unverified.py similarity index 97% rename from timed/reports/tests/test_notify_reviewers_unverified.py rename to timed/notifications/tests/test_notify_reviewers_unverified.py index 4cd2f63c5..b008997da 100644 --- a/timed/reports/tests/test_notify_reviewers_unverified.py +++ b/timed/notifications/tests/test_notify_reviewers_unverified.py @@ -4,6 +4,7 @@ from django.core.management import call_command from timed.employment.factories import UserFactory +from timed.notifications.models import Notification from timed.projects.factories import ( ProjectAssigneeFactory, ProjectFactory, @@ -87,6 +88,7 @@ def test_notify_reviewers(db, mailoutbox): "toDate=2017-07-31&reviewer=%d&editable=1" ) % reviewer_work.id assert url in mail.body + assert Notification.objects.count() == 1 @pytest.mark.freeze_time("2017-8-4") diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/notifications/tests/test_notify_supervisors_shorttime.py similarity index 92% rename from timed/reports/tests/test_notify_supervisors_shorttime.py rename to timed/notifications/tests/test_notify_supervisors_shorttime.py index e7693d585..e6c001742 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/notifications/tests/test_notify_supervisors_shorttime.py @@ -5,6 +5,7 @@ from django.core.management import call_command from timed.employment.factories import EmploymentFactory, UserFactory +from timed.notifications.models import Notification from timed.projects.factories import TaskFactory from timed.tracking.factories import ReportFactory @@ -44,6 +45,7 @@ def test_notify_supervisors(db, mailoutbox): supervisee.get_full_name() ) assert expected in body + assert Notification.objects.count() == 1 def test_notify_supervisors_no_employment(db, mailoutbox): @@ -55,3 +57,4 @@ def test_notify_supervisors_no_employment(db, mailoutbox): call_command("notify_supervisors_shorttime") assert len(mailoutbox) == 0 + assert Notification.objects.count() == 0 diff --git a/timed/reports/management/__init__.py b/timed/reports/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/timed/reports/management/commands/__init__.py b/timed/reports/management/commands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/timed/subscription/tests/test_order.py b/timed/subscription/tests/test_order.py index 6fb38ad81..95b5c5f71 100644 --- a/timed/subscription/tests/test_order.py +++ b/timed/subscription/tests/test_order.py @@ -4,6 +4,7 @@ from django.urls import reverse from rest_framework import status +from timed.notifications.models import Notification from timed.projects.factories import CustomerAssigneeFactory, ProjectFactory from timed.subscription import factories @@ -204,6 +205,7 @@ def test_order_create( assert str(project.name) in mail.body assert "0:30:00" in mail.body assert url in mail.alternatives[0][0] + assert Notification.objects.count() == 1 @pytest.mark.parametrize( diff --git a/timed/subscription/views.py b/timed/subscription/views.py index 941afaefd..33e51f4cd 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -2,6 +2,7 @@ from rest_framework import decorators, exceptions, response, status, viewsets from rest_framework_json_api.serializers import ValidationError +from timed.notifications import notify_admin from timed.permissions import ( IsAccountant, IsAuthenticated, @@ -13,7 +14,7 @@ from timed.projects.filters import ProjectFilterSet from timed.projects.models import CustomerAssignee, Project -from . import filters, models, notify_admin, serializers +from . import filters, models, serializers class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): From 91154c81b8635526bff195d5cb90182404a307f2 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 14 Dec 2022 10:41:19 +0100 Subject: [PATCH 924/980] fx(cmd): fix collectstatic command in cmd.sh --- cmd.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd.sh b/cmd.sh index 2f0b2bb78..32af121fa 100755 --- a/cmd.sh +++ b/cmd.sh @@ -4,7 +4,7 @@ set -x -./manage.py manage.py collectstatic --noinput +./manage.py collectstatic --noinput set -e From a3ab8acb5be4107ea7f4f6677cdbdb57dd0b95c2 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 4 Jan 2023 16:13:37 +0100 Subject: [PATCH 925/980] fix(reports): fix project and customer statistics fix ordering for project and customer statistics include customer serializer for project statistics add tests for filtering customer and project statistics --- timed/reports/serializers.py | 7 +- .../reports/tests/test_customer_statistic.py | 50 +++++++++++-- timed/reports/tests/test_project_statistic.py | 72 +++++++++++++++++-- timed/reports/views.py | 9 +-- 4 files changed, 122 insertions(+), 16 deletions(-) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 578f708a5..6c9329db3 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -8,7 +8,7 @@ Serializer, ) -from timed.projects.models import Project +from timed.projects.models import Customer, Project from timed.serializers import TotalTimeRootMetaMixin @@ -33,8 +33,6 @@ class CustomerStatisticSerializer(TotalTimeRootMetaMixin, Serializer): duration = DurationField() name = CharField(read_only=True) - included_serializers = {"customer": "timed.projects.serializers.CustomerSerializer"} - class Meta: resource_name = "customer-statistics" @@ -46,6 +44,9 @@ class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): amount_offered_currency = CharField() amount_invoiced = DecimalField(max_digits=None, decimal_places=2) amount_invoiced_currency = CharField() + customer = relations.ResourceRelatedField(model=Customer, read_only=True) + + included_serializers = {"customer": "timed.projects.serializers.CustomerSerializer"} class Meta: resource_name = "project-statistics" diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 768465100..871786740 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -5,7 +5,8 @@ from rest_framework import status from timed.conftest import setup_customer_and_employment_status -from timed.employment.factories import EmploymentFactory +from timed.employment.factories import EmploymentFactory, UserFactory +from timed.projects.factories import CostCenterFactory, TaskAssigneeFactory, TaskFactory from timed.tracking.factories import ReportFactory @@ -43,9 +44,7 @@ def test_customer_statistic_list( url = reverse("customer-statistic-list") with django_assert_num_queries(expected): - result = auth_client.get( - url, data={"ordering": "duration", "include": "customer"} - ) + result = auth_client.get(url, data={"ordering": "duration"}) assert result.status_code == status_code if status_code == status.HTTP_200_OK: @@ -72,6 +71,49 @@ def test_customer_statistic_list( assert json["meta"]["total-time"] == "07:00:00" +@pytest.mark.parametrize( + "filter, expected_result", + [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], +) +def test_customer_statistic_filtered(auth_client, filter, expected_result): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=True, + is_customer=True, + is_employed=True, + is_external=False, + ) + + cost_center = CostCenterFactory() + task_z = TaskFactory.create(name="Z", cost_center=cost_center) + task_test = TaskFactory.create(name="Test") + reviewer = TaskAssigneeFactory(user=UserFactory(), task=task_test, is_reviewer=True) + + ReportFactory.create(duration=timedelta(hours=1), date="2022-08-05", task=task_test) + ReportFactory.create(duration=timedelta(hours=2), date="2022-08-30", task=task_test) + ReportFactory.create(duration=timedelta(hours=3), date="2022-09-01", task=task_z) + + filter_values = { + "from_date": "2022-08-20", # last two reports + "customer": str(task_test.project.customer.pk), # first two + "cost_center": str(cost_center.pk), # first two + "reviewer": str(reviewer.user.pk), # first two + } + the_filter = {filter: filter_values[filter]} + + url = reverse("customer-statistic-list") + result = auth_client.get( + url, + data={"ordering": "name", **the_filter}, + ) + assert result.status_code == status.HTTP_200_OK + + json = result.json() + + assert json["meta"]["total-time"] == f"{expected_result:02}:00:00" + + @pytest.mark.parametrize( "is_employed, expected, status_code", [ diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index 2a8152744..5ba4c6331 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -5,7 +5,8 @@ from rest_framework import status from timed.conftest import setup_customer_and_employment_status -from timed.projects.factories import TaskFactory +from timed.employment.factories import UserFactory +from timed.projects.factories import CostCenterFactory, TaskAssigneeFactory, TaskFactory from timed.tracking.factories import ReportFactory @@ -14,9 +15,9 @@ [ (False, True, False, 1, status.HTTP_403_FORBIDDEN), (False, True, True, 1, status.HTTP_403_FORBIDDEN), - (True, False, False, 3, status.HTTP_200_OK), - (True, True, False, 3, status.HTTP_200_OK), - (True, True, True, 3, status.HTTP_200_OK), + (True, False, False, 4, status.HTTP_200_OK), + (True, True, False, 4, status.HTTP_200_OK), + (True, True, True, 4, status.HTTP_200_OK), ], ) def test_project_statistic_list( @@ -46,7 +47,9 @@ def test_project_statistic_list( url = reverse("project-statistic-list") with django_assert_num_queries(expected): - result = auth_client.get(url, data={"ordering": "duration"}) + result = auth_client.get( + url, data={"ordering": "duration", "include": "customer"} + ) assert result.status_code == status_code if status_code == status.HTTP_200_OK: @@ -63,6 +66,14 @@ def test_project_statistic_list( "amount-invoiced": str(project_2.amount_invoiced.amount), "amount-invoiced-currency": project_2.amount_invoiced_currency, }, + "relationships": { + "customer": { + "data": { + "type": "customers", + "id": str(project_2.customer.id), + } + } + }, }, { "type": "project-statistics", @@ -75,7 +86,58 @@ def test_project_statistic_list( "amount-invoiced": str(project.amount_invoiced.amount), "amount-invoiced-currency": project.amount_invoiced_currency, }, + "relationships": { + "customer": { + "data": { + "type": "customers", + "id": str(project.customer.id), + } + } + }, }, ] assert json["data"] == expected_json assert json["meta"]["total-time"] == "09:00:00" + + +@pytest.mark.parametrize( + "filter, expected_result", + [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], +) +def test_project_statistic_filtered(auth_client, filter, expected_result): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=True, + is_customer=True, + is_employed=True, + is_external=False, + ) + + cost_center = CostCenterFactory() + task_z = TaskFactory.create(name="Z", cost_center=cost_center) + task_test = TaskFactory.create(name="Test") + reviewer = TaskAssigneeFactory(user=UserFactory(), task=task_test, is_reviewer=True) + + ReportFactory.create(duration=timedelta(hours=1), date="2022-08-05", task=task_test) + ReportFactory.create(duration=timedelta(hours=2), date="2022-08-30", task=task_test) + ReportFactory.create(duration=timedelta(hours=3), date="2022-09-01", task=task_z) + + filter_values = { + "from_date": "2022-08-20", # last two reports + "customer": str(task_test.project.customer.pk), # first two + "cost_center": str(cost_center.pk), # first two + "reviewer": str(reviewer.user.pk), # first two + } + the_filter = {filter: filter_values[filter]} + + url = reverse("project-statistic-list") + result = auth_client.get( + url, + data={"ordering": "name", "include": "customer", **the_filter}, + ) + assert result.status_code == status.HTTP_200_OK + + json = result.json() + + assert json["meta"]["total-time"] == f"{expected_result:02}:00:00" diff --git a/timed/reports/views.py b/timed/reports/views.py index 68878154e..8b52c6f6e 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -75,8 +75,8 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.CustomerStatisticSerializer filterset_class = TaskStatisticFilterSet - ordering_fields = ("project__customer__name", "duration") - ordering = ("project__customer__name",) + ordering_fields = ("name", "duration") + ordering = ("name",) permission_classes = [ # internal employees or super users may read all customer statistics (IsInternal | IsSuperUser) @@ -99,8 +99,8 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.ProjectStatisticSerializer filterset_class = TaskStatisticFilterSet - ordering_fields = ("project__name", "duration") - ordering = ("project__name",) + ordering_fields = ("name", "duration") + ordering = ("name",) permission_classes = [ # internal employees or super users may read all customer statistics (IsInternal | IsSuperUser) @@ -116,6 +116,7 @@ def get_queryset(self): "amount_offered_currency": F("amount_offered_currency"), "amount_invoiced": F("amount_invoiced"), "amount_invoiced_currency": F("amount_invoiced_currency"), + "customer": F("customer"), }, ) return queryset From 89fb718901f41914323a60d99a2983ba0454daa0 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 16 Jan 2023 09:53:54 +0100 Subject: [PATCH 926/980] fix(statistics): add missing fields for project and task statistics add estimated_time field for task statistics add estimated_time and total_remaining_effort for project statistics --- timed/reports/serializers.py | 3 +++ timed/reports/tests/test_project_statistic.py | 4 ++++ timed/reports/tests/test_task_statistic.py | 2 ++ timed/reports/views.py | 3 +++ 4 files changed, 12 insertions(+) diff --git a/timed/reports/serializers.py b/timed/reports/serializers.py index 6c9329db3..abbf4901a 100644 --- a/timed/reports/serializers.py +++ b/timed/reports/serializers.py @@ -45,6 +45,8 @@ class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): amount_invoiced = DecimalField(max_digits=None, decimal_places=2) amount_invoiced_currency = CharField() customer = relations.ResourceRelatedField(model=Customer, read_only=True) + estimated_time = DurationField(read_only=True) + total_remaining_effort = DurationField(read_only=True) included_serializers = {"customer": "timed.projects.serializers.CustomerSerializer"} @@ -57,6 +59,7 @@ class TaskStatisticSerializer(TotalTimeRootMetaMixin, Serializer): most_recent_remaining_effort = DurationField(read_only=True) duration = DurationField(read_only=True) project = relations.ResourceRelatedField(model=Project, read_only=True) + estimated_time = DurationField(read_only=True) included_serializers = {"project": "timed.projects.serializers.ProjectSerializer"} diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index 5ba4c6331..d521ae052 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -65,6 +65,8 @@ def test_project_statistic_list( "amount-offered-currency": project_2.amount_offered_currency, "amount-invoiced": str(project_2.amount_invoiced.amount), "amount-invoiced-currency": project_2.amount_invoiced_currency, + "estimated-time": "00:00:00", + "total-remaining-effort": "00:00:00", }, "relationships": { "customer": { @@ -85,6 +87,8 @@ def test_project_statistic_list( "amount-offered-currency": project.amount_offered_currency, "amount-invoiced": str(project.amount_invoiced.amount), "amount-invoiced-currency": project.amount_invoiced_currency, + "estimated-time": "00:00:00", + "total-remaining-effort": "00:00:00", }, "relationships": { "customer": { diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index 50255cb57..6832fd0fc 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -64,6 +64,7 @@ def test_task_statistic_list( "duration": "03:00:00", "name": str(task_test.name), "most-recent-remaining-effort": None, + "estimated-time": "00:00:00", }, "relationships": { "project": { @@ -78,6 +79,7 @@ def test_task_statistic_list( "duration": "02:00:00", "name": str(task_z.name), "most-recent-remaining-effort": None, + "estimated-time": "00:00:00", }, "relationships": { "project": { diff --git a/timed/reports/views.py b/timed/reports/views.py index 8b52c6f6e..e34f40c77 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -117,6 +117,8 @@ def get_queryset(self): "amount_invoiced": F("amount_invoiced"), "amount_invoiced_currency": F("amount_invoiced_currency"), "customer": F("customer"), + "estimated_time": F("estimated_time"), + "total_remaining_effort": F("total_remaining_effort"), }, ) return queryset @@ -141,6 +143,7 @@ def get_queryset(self): annotations={ "name": F("name"), "project": F("project"), + "estimated_time": F("estimated_time"), "most_recent_remaining_effort": F("most_recent_remaining_effort"), }, ) From fc7c92cf0f3cb937100616abb24bd06804408a51 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 16 Jan 2023 14:05:49 +0100 Subject: [PATCH 927/980] fix(tracking): fix remaining effort check when creating report Check if remaining effort tracking is active on the corresponding project when creating a new report as well. Previously the check only worked when updating a report, but not when creating one. --- timed/tracking/serializers.py | 16 +++++----- timed/tracking/tests/test_report.py | 48 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index a56c7dd82..d45ecbe96 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -136,14 +136,6 @@ def validate_rejected(self, value): return value - def validate_remaining_effort(self, value): - """Only update remaining effort when tracking is active on the corresponding project.""" - if not self.instance.task.project.remaining_effort_tracking: - raise ValidationError( - "Remaining effort tracking is not active on this project!" - ) - return value - def validate(self, data): """ Validate that verified by is only set by reviewer or superuser. @@ -152,6 +144,8 @@ def validate(self, data): needs review. External employees with manager or reviewer role may not create reports. + + Check if remaing effort tracking is active on the corresponding project. """ user = self.context["request"].user @@ -181,6 +175,12 @@ def validate(self, data): ).exists() ) + # check if remaining effort tracking is active on the corresponding project + if not task.project.remaining_effort_tracking and data.get("remaining_effort"): + raise ValidationError( + "Remaining effort tracking is not active on this project!" + ) + if new_verified_by != current_verified_by: if not is_reviewer: raise ValidationError(_("Only reviewer may verify reports.")) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 7af5e0e9c..238fb4965 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1854,6 +1854,54 @@ def test_report_set_remaining_effort( assert response.status_code == expected +@pytest.mark.parametrize( + "remaining_effort_active, expected", + [ + (True, status.HTTP_201_CREATED), + (False, status.HTTP_400_BAD_REQUEST), + ], +) +def test_report_create_remaining_effort( + internal_employee_client, + report_factory, + project_factory, + task_factory, + remaining_effort_active, + expected, +): + user = internal_employee_client.user + project = project_factory.create( + billed=True, remaining_effort_tracking=remaining_effort_active + ) + task = task_factory.create(project=project) + + data = { + "data": { + "type": "reports", + "id": None, + "attributes": { + "comment": "foo", + "duration": "00:15:00", + "date": "2022-02-01", + "remaining_effort": "01:00:00", + }, + "relationships": { + "task": {"data": {"type": "tasks", "id": task.id}}, + }, + } + } + + url = reverse("report-list") + + response = internal_employee_client.post(url, data) + assert response.status_code == expected + + if expected == status.HTTP_201_CREATED: + json = response.json() + assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) + assert json["data"]["relationships"]["task"]["data"]["id"] == str(task.id) + + def test_report_remaining_effort_total( internal_employee_client, report_factory, From 50e5da2ad5ef12098e0128ba907ac40ac2fa1773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Birrer?= Date: Tue, 24 Jan 2023 16:16:10 +0100 Subject: [PATCH 928/980] fix(dev): remove deprecated flag from pre-commit isort --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49bbe46e7..9c483cbd2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: isort name: isort language: system - entry: isort -y + entry: isort types: [python] - id: flake8 name: flake8 From 8454601019f33272a39814ac8e3fe033c758e7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Birrer?= Date: Tue, 24 Jan 2023 16:52:11 +0100 Subject: [PATCH 929/980] fix(auth): let failing auth requests return 401 --- timed/authentication.py | 34 +++++++++++++++++++----------- timed/tests/test_authentication.py | 20 ++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/timed/authentication.py b/timed/authentication.py index 9efc20724..e5f7dac71 100644 --- a/timed/authentication.py +++ b/timed/authentication.py @@ -8,6 +8,7 @@ from django.core.exceptions import SuspiciousOperation from django.utils.encoding import force_bytes from mozilla_django_oidc.auth import LOGGER, OIDCAuthenticationBackend +from rest_framework.exceptions import AuthenticationFailed class TimedOIDCAuthenticationBackend(OIDCAuthenticationBackend): @@ -37,20 +38,29 @@ def get_userinfo_or_introspection(self, access_token): claims = self.cached_request( self.get_userinfo, access_token, "auth.userinfo" ) + return claims except requests.HTTPError as e: - if not ( - e.response.status_code in [401, 403] and settings.OIDC_CHECK_INTROSPECT - ): + if e.response.status_code not in [401, 403]: raise e - - # check introspection if userinfo fails (confidental client) - claims = self.cached_request( - self.get_introspection, access_token, "auth.introspection" - ) - if "client_id" not in claims: - raise SuspiciousOperation("client_id not present in introspection") - - return claims + if settings.OIDC_CHECK_INTROSPECT: + try: + # check introspection if userinfo fails (confidential client) + claims = self.cached_request( + self.get_introspection, access_token, "auth.introspection" + ) + if "client_id" not in claims: + raise SuspiciousOperation( + "client_id not present in introspection" + ) + return claims + except requests.HTTPError as e: + # if the authorization fails it's not a valid client or + # the token is expired and permission is denied. + # Handing on the 401 Client Error would be transformed into + # a 500 by Django's exception handling. But that's not what we want. + if e.response.status_code not in [401, 403]: # pragma: no cover + raise e + raise AuthenticationFailed() def get_or_create_user(self, access_token, id_token, payload): """Verify claims and return user, otherwise raise an Exception.""" diff --git a/timed/tests/test_authentication.py b/timed/tests/test_authentication.py index 4667555ba..d18c8ca93 100644 --- a/timed/tests/test_authentication.py +++ b/timed/tests/test_authentication.py @@ -8,6 +8,7 @@ from requests.exceptions import HTTPError from rest_framework import exceptions, status from rest_framework.exceptions import AuthenticationFailed +from rest_framework.reverse import reverse from timed.employment.factories import UserFactory @@ -144,3 +145,22 @@ def test_authentication_no_client(db, rf, requests_mock, settings): request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") with pytest.raises(AuthenticationFailed): OIDCAuthentication().authenticate(request) + + +@pytest.mark.parametrize("check_introspect", [True, False]) +def test_userinfo_introspection_failure( + db, client, rf, requests_mock, settings, check_introspect +): + settings.OIDC_CHECK_INTROSPECT = check_introspect + requests_mock.get( + settings.OIDC_OP_USER_ENDPOINT, status_code=status.HTTP_401_UNAUTHORIZED + ) + requests_mock.post( + settings.OIDC_OP_INTROSPECT_ENDPOINT, status_code=status.HTTP_403_FORBIDDEN + ) + resp = client.get(reverse("user-me"), HTTP_AUTHORIZATION="Bearer Token") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + request = rf.get("/openid", HTTP_AUTHORIZATION="Bearer Token") + with pytest.raises(AuthenticationFailed): + OIDCAuthentication().authenticate(request) + cache.clear() From ee8f79a1a724763bdd51222010f47ec40ef71622 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 19 Jan 2023 10:17:47 +0100 Subject: [PATCH 930/980] fix: add missing rejected field to ReportIntersectionSerializer --- timed/tracking/serializers.py | 4 ++++ timed/tracking/tests/test_report.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index d45ecbe96..e2888dde1 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -303,6 +303,7 @@ class ReportIntersectionSerializer(Serializer): not_billable = SerializerMethodField() billed = SerializerMethodField() verified = SerializerMethodField() + rejected = SerializerMethodField() def _intersection(self, instance, field, model=None): """Get intersection of given field. @@ -356,6 +357,9 @@ def get_verified(self, instance): instance["queryset"] = queryset return self._intersection(instance, "verified") + def get_rejected(self, instance): + return self._intersection(instance, "rejected") + def get_root_meta(self, resource, many): """Add number of results to meta.""" queryset = self.instance["queryset"] diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 238fb4965..17b6e5812 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -83,6 +83,7 @@ def test_report_intersection_full( "verified": False, "review": False, "billed": False, + "rejected": False, }, "relationships": { "customer": { @@ -129,6 +130,7 @@ def test_report_intersection_partial( "verified": None, "review": None, "billed": None, + "rejected": False, }, "relationships": { "customer": {"data": None}, @@ -173,6 +175,7 @@ def test_report_intersection_accountant_editable( "verified": False, "review": True, "billed": None, + "rejected": False, }, "relationships": { "customer": {"data": None}, @@ -217,6 +220,7 @@ def test_report_intersection_accountant_not_editable( "verified": None, "review": None, "billed": None, + "rejected": None, }, "relationships": { "customer": {"data": None}, From d884ef6a4463e5095fcecc6cd999aa6b595f5530 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 12 Jan 2023 13:09:27 +0100 Subject: [PATCH 931/980] fix(tracking): fix absence for users with multiple employments 1. fix: move the PublicHoliday exclusion from absence object manager to AbsenceViewSet get_queryset() 2. fix: only exclude public holidays from the current employment. This was an issue for users with multiple employments (past and current) at different locations, as locations may have different public holidays. --- timed/tracking/models.py | 16 ---------------- timed/tracking/views.py | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/timed/tracking/models.py b/timed/tracking/models.py index f9c7adf1f..a79eb2dd1 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -129,21 +129,6 @@ class Meta: indexes = [models.Index(fields=["date"])] -class AbsenceManager(models.Manager): - def get_queryset(self): - from timed.employment.models import PublicHoliday - - queryset = super().get_queryset() - queryset = queryset.exclude( - date__in=models.Subquery( - PublicHoliday.objects.filter( - location__employments__user=models.OuterRef("user") - ).values("date") - ) - ) - return queryset - - class Absence(models.Model): """Absence model. @@ -159,7 +144,6 @@ class Absence(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="absences" ) - objects = AbsenceManager() def calculate_duration(self, employment): """ diff --git a/timed/tracking/views.py b/timed/tracking/views.py index c8e29f708..46bca34da 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from timed.employment.models import Employment +from timed.employment.models import Employment, PublicHoliday from timed.permissions import ( IsAccountant, IsAuthenticated, @@ -371,13 +371,23 @@ class AbsenceViewSet(ModelViewSet): ] def get_queryset(self): - """Get absences only for internal employees.""" + """Get absences only for internal employees. + + User should be able to create an absence on a public holiday if the + public holiday is only on user's previous employment location. + """ user = self.request.user if user.is_superuser: queryset = models.Absence.objects.select_related("absence_type", "user") return queryset - queryset = models.Absence.objects.select_related("absence_type", "user").filter( - Q(user=user) | Q(user__in=user.supervisees.all()) + queryset = ( + models.Absence.objects.select_related("absence_type", "user") + .filter(Q(user=user) | Q(user__in=user.supervisees.all())) + .exclude( + date__in=PublicHoliday.objects.filter( + location=user.get_active_employment().location + ).values("date") + ) ) return queryset From 7dc1574803539107b97ea247fde56f2b5b9052fc Mon Sep 17 00:00:00 2001 From: Wiktork Date: Fri, 3 Feb 2023 14:33:28 +0100 Subject: [PATCH 932/980] chore(deps): bump dependencies bump certifi from 2022.9.24 to 2022.12.7 bump sentry-sdk from 1.10.1 to 1.14.0 bump pytest-cov from 3.0.0 to 4.0.0 bump pytest from 7.1.2 to 7.2.1 --- poetry.lock | 72 +++++++++++++++++++++++--------------------------- pyproject.toml | 4 +-- 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9fbdb2b26..54ed37ba4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,14 +31,6 @@ six = "*" [package.extras] test = ["astroid (<=2.5.3)", "pytest"] -[[package]] -name = "atomicwrites" -version = "1.4.1" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "attrs" version = "22.1.0" @@ -96,7 +88,7 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2022.9.24" +version = "2022.12.7" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -377,6 +369,17 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "exceptiongroup" +version = "1.1.0" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "executing" version = "1.2.0" @@ -894,14 +897,6 @@ python-versions = "*" [package.extras] tests = ["pytest"] -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - [[package]] name = "py-moneyed" version = "1.2" @@ -1091,28 +1086,27 @@ python-versions = "*" [[package]] name = "pytest" -version = "7.1.2" +version = "7.2.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-cov" -version = "3.0.0" +version = "4.0.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false @@ -1268,7 +1262,7 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 [[package]] name = "sentry-sdk" -version = "1.10.1" +version = "1.14.0" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false @@ -1289,13 +1283,16 @@ falcon = ["falcon (>=1.4)"] fastapi = ["fastapi (>=0.79.0)"] flask = ["blinker (>=1.1)", "flask (>=0.11)"] httpx = ["httpx (>=0.16.0)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"] pyspark = ["pyspark (>=2.4.4)"] quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] rq = ["rq (>=0.6)"] sanic = ["sanic (>=0.8)"] sqlalchemy = ["sqlalchemy (>=1.2)"] starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] tornado = ["tornado (>=5)"] [[package]] @@ -1516,7 +1513,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "7968ad9d51a0ce628f11d15648aa03c0fc3eff977743720cdfbb216efb830250" +content-hash = "05a2652964806a1e455e868e9b5f1f9d80d68f76ca3eff4c134b638a2ccd98f0" [metadata.files] appnope = [ @@ -1531,9 +1528,6 @@ asttokens = [ {file = "asttokens-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b28ed85e254b724439afc783d4bee767f780b936c3fe8b3275332f42cf5f561"}, {file = "asttokens-2.1.0.tar.gz", hash = "sha256:4aa76401a151c8cc572d906aad7aea2a841780834a19d780f4321c0fe1b54635"}, ] -atomicwrites = [ - {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, -] attrs = [ {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, @@ -1572,8 +1566,8 @@ black = [ {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, ] certifi = [ - {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, - {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, ] cffi = [ {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, @@ -1788,6 +1782,10 @@ et-xmlfile = [ {file = "et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"}, {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, ] +exceptiongroup = [ + {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, + {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, +] executing = [ {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"}, {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"}, @@ -2095,10 +2093,6 @@ pure-eval = [ {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, ] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] py-moneyed = [ {file = "py-moneyed-1.2.tar.gz", hash = "sha256:d745a52819604f42b3666f9b2504b71c27c1645d6d5027d95ec5ed1f28ca86e3"}, {file = "py_moneyed-1.2-py2.py3-none-any.whl", hash = "sha256:c6131c7b7c1f8503552afe44d15c343ea50282d1d9e6fa8b3f1bd2affc1dae1e"}, @@ -2162,12 +2156,12 @@ pyrepl = [ {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, ] pytest = [ - {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, - {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, + {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, + {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, ] pytest-cov = [ - {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, - {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, ] pytest-django = [ {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, @@ -2213,8 +2207,8 @@ requests-mock = [ {file = "requests_mock-1.9.3-py2.py3-none-any.whl", hash = "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970"}, ] sentry-sdk = [ - {file = "sentry-sdk-1.10.1.tar.gz", hash = "sha256:105faf7bd7b7fa25653404619ee261527266b14103fe1389e0ce077bd23a9691"}, - {file = "sentry_sdk-1.10.1-py2.py3-none-any.whl", hash = "sha256:06c0fa9ccfdc80d7e3b5d2021978d6eb9351fa49db9b5847cf4d1f2a473414ad"}, + {file = "sentry-sdk-1.14.0.tar.gz", hash = "sha256:273fe05adf052b40fd19f6d4b9a5556316807246bd817e5e3482930730726bb0"}, + {file = "sentry_sdk-1.14.0-py2.py3-none-any.whl", hash = "sha256:72c00322217d813cf493fe76590b23a757e063ff62fec59299f4af7201dd4448"}, ] setuptools = [ {file = "setuptools-65.5.1-py3-none-any.whl", hash = "sha256:d0b9a8433464d5800cbe05094acf5c6d52a91bfac9b52bcfc4d41382be5d5d31"}, diff --git a/pyproject.toml b/pyproject.toml index ca2169453..1add20883 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,8 +61,8 @@ flake8-string-format = "0.3.0" ipdb = "0.13.9" isort = "5.10.1" pdbpp = "0.10.3" -pytest = "7.1.2" -pytest-cov = "3.0.0" +pytest = "7.2.1" +pytest-cov = "4.0.0" pytest-django = "4.5.2" pytest-env = "0.6.2" # needs to stay at 2.1.0 because of wrong interpretation of parameters with "__" From 08a5aa429eac6d25cec0699a42919ee8f959ed12 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 9 Feb 2023 09:44:55 +0100 Subject: [PATCH 933/980] fix(tracking): allow null values on remaining effort for reports --- .../0017_alter_report_remaining_effort.py | 19 +++++++++++++++++++ timed/tracking/models.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 timed/tracking/migrations/0017_alter_report_remaining_effort.py diff --git a/timed/tracking/migrations/0017_alter_report_remaining_effort.py b/timed/tracking/migrations/0017_alter_report_remaining_effort.py new file mode 100644 index 000000000..8fc7ae418 --- /dev/null +++ b/timed/tracking/migrations/0017_alter_report_remaining_effort.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.16 on 2023-02-09 08:41 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tracking", "0016_report_remaining_effort"), + ] + + operations = [ + migrations.AlterField( + model_name="report", + name="remaining_effort", + field=models.DurationField(default=datetime.timedelta(0), null=True), + ), + ] diff --git a/timed/tracking/models.py b/timed/tracking/models.py index a79eb2dd1..b216c2cc3 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -101,7 +101,7 @@ class Report(models.Model): added = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) rejected = models.BooleanField(default=False) - remaining_effort = models.DurationField(default=timedelta(0)) + remaining_effort = models.DurationField(default=timedelta(0), null=True) def save(self, *args, **kwargs): """Save the report with some custom functionality. From 91a6dd5ec2d128d6df3994c78eebbb295ec9a2f5 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 13 Feb 2023 15:07:44 +0100 Subject: [PATCH 934/980] fix(notifications): omit projects with no reports --- .../management/commands/budget_check.py | 17 +++++++++-------- timed/notifications/tests/test_budget_check.py | 8 +++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/timed/notifications/management/commands/budget_check.py b/timed/notifications/management/commands/budget_check.py index d816b6948..0a4442cbb 100644 --- a/timed/notifications/management/commands/budget_check.py +++ b/timed/notifications/management/commands/budget_check.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import redminelib from django.conf import settings from django.core.management.base import BaseCommand @@ -27,6 +29,7 @@ def handle(self, *args, **options): cost_center__name__contains=settings.BUILD_PROJECTS, redmine_project__isnull=False, estimated_time__isnull=False, + estimated_time__gt=timedelta(hours=0), ) .exclude(notifications__notification_type=Notification.BUDGET_CHECK_70) .order_by("name") @@ -37,17 +40,15 @@ def handle(self, *args, **options): Report.objects.filter(task__project=project, not_billable=False) .aggregate(billable_hours=Sum("duration")) .get("billable_hours") - ).total_seconds() / 3600 - estimated_hours = project.estimated_time.total_seconds() / 3600 + ) - try: - budget_percentage = billable_hours / estimated_hours - except ZeroDivisionError: - self.stdout.write( - self.style.WARNING(f"Project {project.name} has no estimated time!") - ) + if not billable_hours: continue + billable_hours = billable_hours.total_seconds() / 3600 + estimated_hours = project.estimated_time.total_seconds() / 3600 + budget_percentage = billable_hours / estimated_hours + if budget_percentage <= 0.3: continue try: diff --git a/timed/notifications/tests/test_budget_check.py b/timed/notifications/tests/test_budget_check.py index f5cd6de32..0b4b45693 100644 --- a/timed/notifications/tests/test_budget_check.py +++ b/timed/notifications/tests/test_budget_check.py @@ -12,7 +12,7 @@ @pytest.mark.parametrize( "duration, percentage_exceeded, notification_count", - [(1, 0, 0), (3, 0, 0), (4, 30, 1), (8, 70, 2)], + [(1, 0, 0), (3, 0, 0), (4, 30, 1), (8, 70, 2), (0, 0, 0)], ) def test_budget_check_1( db, mocker, report_factory, duration, percentage_exceeded, notification_count @@ -31,6 +31,9 @@ def test_budget_check_1( project.cost_center.name = "DEV_BUILD" project.cost_center.save() + if duration == 0: + report.delete() + if percentage_exceeded == 70: NotificationFactory( project=project, notification_type=Notification.BUDGET_CHECK_30 @@ -98,8 +101,7 @@ def test_budget_check_no_estimated_timed(db, mocker, capsys, report_factory): call_command("budget_check") - out, _ = capsys.readouterr() - assert f"Project {project.name} has no estimated time!" in out + assert Notification.objects.count() == 0 def test_budget_check_invalid_issue(db, mocker, capsys, report_factory): From 16f1dbb54f625a8468fd33066a685ac1cfae7fec Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 14 Feb 2023 17:09:46 +0100 Subject: [PATCH 935/980] fix(tracking): fix setting of remaining effort Change signal to pre_save and update most_recent_remaining_effort on task and total_remaining_effort on project only if the value of remaining_effort on report has changed. --- timed/tracking/signals.py | 28 ++++++++---- timed/tracking/tests/test_report.py | 68 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/timed/tracking/signals.py b/timed/tracking/signals.py index f7fd8df8d..102b04d62 100644 --- a/timed/tracking/signals.py +++ b/timed/tracking/signals.py @@ -1,5 +1,5 @@ from django.db.models import Sum -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import pre_save from django.dispatch import receiver from timed.tracking.models import Report @@ -15,15 +15,27 @@ def update_rejected_on_reports(sender, instance, **kwargs): instance.rejected = False -@receiver(post_save, sender=Report) +@receiver(pre_save, sender=Report) def update_most_recent_remaining_effort(sender, instance, **kwargs): - """Update remaining effort on task, if remaining effort tracking is active.""" - if instance.task.project.remaining_effort_tracking: + """Update remaining effort on task, if remaining effort tracking is active. + + Update most_recent_remaining_effort on task and total_remaining_effort on project + only if remaining effort on report has changed. + Any other change on report should not trigger this signal. + """ + if kwargs.get("raw", False): # pragma: no cover + return + + if not instance.pk: + return + if instance.task.project.remaining_effort_tracking is not True: + return + + if instance.remaining_effort != Report.objects.get(id=instance.id).remaining_effort: task = instance.task - last_report = task.reports.order_by("date").last() - if instance == last_report: - task.most_recent_remaining_effort = instance.remaining_effort - task.save() + task.most_recent_remaining_effort = instance.remaining_effort + task.save() + project = task.project total_remaining_effort = ( project.tasks.all() diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 17b6e5812..6343ec0f8 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1954,3 +1954,71 @@ def test_report_remaining_effort_total( task_2.refresh_from_db() assert task_2.most_recent_remaining_effort == timedelta(hours=3) assert task_2.project.total_remaining_effort == timedelta(hours=4) + + +def test_report_remaining_effort_update( + internal_employee_client, + report_factory, +): + user = internal_employee_client.user + report = report_factory.create(user=user, date="2022-02-01") + report_2 = report_factory.create(user=user, task=report.task, date="2022-02-01") + report.task.project.remaining_effort_tracking = True + report.task.project.save() + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": { + "remaining_effort": "01:00:00", + }, + } + } + + url = reverse("report-detail", args=[report.id]) + + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.task.refresh_from_db() + assert report.task.most_recent_remaining_effort == timedelta(hours=1) + assert report.task.project.total_remaining_effort == timedelta(hours=1) + + data = { + "data": { + "type": "reports", + "id": report_2.id, + "attributes": { + "remaining_effort": "03:00:00", + }, + } + } + + url = reverse("report-detail", args=[report_2.id]) + + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.task.refresh_from_db() + assert report.task.most_recent_remaining_effort == timedelta(hours=3) + assert report.task.project.total_remaining_effort == timedelta(hours=3) + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": { + "comment": "foo bar", + }, + } + } + + url = reverse("report-detail", args=[report.id]) + + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + + report.task.refresh_from_db() + assert report.task.most_recent_remaining_effort == timedelta(hours=3) + assert report.task.project.total_remaining_effort == timedelta(hours=3) From abc50834feb3f84d3018abaa31073ab68e79dd76 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 13 Feb 2023 11:29:52 +0100 Subject: [PATCH 936/980] feat(redmine): add pretend mode to redmine commands Add pretend mode for update_project_expenditure and import_project_data commands so they can be tested without using productive data on test systems. --- .../commands/import_project_data.py | 17 ++++++++++- .../commands/update_project_expenditure.py | 29 +++++++++++++++---- .../tests/test_update_project_expenditure.py | 27 +++++++++++------ 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/timed/redmine/management/commands/import_project_data.py b/timed/redmine/management/commands/import_project_data.py index 3e83216e2..d65270ac9 100644 --- a/timed/redmine/management/commands/import_project_data.py +++ b/timed/redmine/management/commands/import_project_data.py @@ -8,6 +8,13 @@ class Command(BaseCommand): # pragma: no cover help = "Update projects" + def add_arguments(self, parser): + parser.add_argument( + "--pretend", + action="store_true", + help="Pretend mode for testing", + ) + def handle(self, *args, **options): redmine = redminelib.Redmine( settings.REDMINE_URL, @@ -30,6 +37,8 @@ def handle(self, *args, **options): redmine_projects = open_redmine_projects + closed_redmine_projects + pretend = options["pretend"] + for redmine_project in redmine_projects: timed_project = Project.objects.filter( redmine_project__issue_id=redmine_project.id, @@ -58,4 +67,10 @@ def handle(self, *args, **options): if amount_invoiced != "" else timed_project.amount_invoiced ) - timed_project.save() + if not pretend: + timed_project.save() + self.stdout.write( + self.style.SUCCESS( + f"Updating project {timed_project.name} with amount offered {timed_project.amount_offered} and amount invoiced {timed_project.amount_invoiced}" + ) + ) diff --git a/timed/redmine/management/commands/update_project_expenditure.py b/timed/redmine/management/commands/update_project_expenditure.py index b7f426089..038c537dd 100644 --- a/timed/redmine/management/commands/update_project_expenditure.py +++ b/timed/redmine/management/commands/update_project_expenditure.py @@ -9,6 +9,13 @@ class Command(BaseCommand): help = "Update expenditures on associated Redmine projects." + def add_arguments(self, parser): + parser.add_argument( + "--pretend", + action="store_true", + help="Pretend mode for testing", + ) + def handle(self, *args, **options): redmine = redminelib.Redmine( settings.REDMINE_URL, @@ -29,6 +36,8 @@ def handle(self, *args, **options): ) ) + pretend = options["pretend"] + for project in affected_projects.iterator(): estimated_hours = ( project.estimated_time.total_seconds() / 3600 @@ -65,11 +74,19 @@ def handle(self, *args, **options): "value": project.amount_invoiced.amount, }, ] - try: - issue.save() - except redminelib.exceptions.BaseRedmineError as e: # pragma: no cover - self.stdout.write( - self.style.ERROR( - f"Failed to save Project {project.name} with Redmine issue {issue.id}!\n{e}" + if not pretend: + try: + issue.save() + continue + except redminelib.exceptions.BaseRedmineError as e: # pragma: no cover + self.stdout.write( + self.style.ERROR( + f"Failed to save Project {project.name} with Redmine issue {issue.id}!\n{e}" + ) ) + + self.stdout.write( + self.style.SUCCESS( + f"Updating Redmine issue {project.redmine_project.issue_id} with total spent hours {total_spent_hours}, amount offered {project.amount_offered.amount}, amount invoiced {project.amount_invoiced.amount}" ) + ) diff --git a/timed/redmine/tests/test_update_project_expenditure.py b/timed/redmine/tests/test_update_project_expenditure.py index 7fb8eb9f0..aef6c8679 100644 --- a/timed/redmine/tests/test_update_project_expenditure.py +++ b/timed/redmine/tests/test_update_project_expenditure.py @@ -1,12 +1,14 @@ import datetime +import pytest from django.core.management import call_command from redminelib.exceptions import ResourceNotFoundError from timed.redmine.models import RedmineProject -def test_update_project_expenditure(db, mocker, report_factory): +@pytest.mark.parametrize("pretend", [False, True]) +def test_update_project_expenditure(db, mocker, capsys, report_factory, pretend): redmine_instance = mocker.MagicMock() issue = mocker.MagicMock() redmine_instance.issue.get.return_value = issue @@ -20,14 +22,21 @@ def test_update_project_expenditure(db, mocker, report_factory): RedmineProject.objects.create(project=report.task.project, issue_id=1000) - call_command("update_project_expenditure") - - redmine_instance.issue.get.assert_called_once_with(1000) - assert issue.estimated_hours == project.estimated_time.total_seconds() / 3600 - assert issue.custom_fields[0]["value"] == report.duration.total_seconds() / 3600 - assert issue.custom_fields[1]["value"] == project.amount_offered.amount - assert issue.custom_fields[2]["value"] == project.amount_invoiced.amount - issue.save.assert_called_once_with() + call_command("update_project_expenditure", pretend=pretend) + + if not pretend: + redmine_instance.issue.get.assert_called_once_with(1000) + assert issue.estimated_hours == project.estimated_time.total_seconds() / 3600 + assert issue.custom_fields[0]["value"] == report.duration.total_seconds() / 3600 + assert issue.custom_fields[1]["value"] == project.amount_offered.amount + assert issue.custom_fields[2]["value"] == project.amount_invoiced.amount + issue.save.assert_called_once_with() + else: + out, _ = capsys.readouterr() + assert "Redmine issue 1000" in out + assert f"total spent hours {report.duration.total_seconds() / 3600}" in out + assert f"amount offered {project.amount_offered.amount}" in out + assert f"amount invoiced {project.amount_invoiced.amount}" in out def test_update_project_expenditure_invalid_issue( From a05f0993ac41d283b9e5e68408f10679f8996f11 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Feb 2023 12:46:29 +0000 Subject: [PATCH 937/980] chore(deps): bump django from 3.2.16 to 3.2.18 Bumps [django](https://github.com/django/django) from 3.2.16 to 3.2.18. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.16...3.2.18) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- poetry.lock | 1632 ++++++++++++++++++++++++------------------------ pyproject.toml | 2 +- 2 files changed, 817 insertions(+), 817 deletions(-) diff --git a/poetry.lock b/poetry.lock index 54ed37ba4..40aa2378e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,5 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + [[package]] name = "appnope" version = "0.1.3" @@ -5,6 +7,10 @@ description = "Disable App Nap on macOS >= 10.9" category = "dev" optional = false python-versions = "*" +files = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] [[package]] name = "asgiref" @@ -13,6 +19,10 @@ description = "ASGI specs, helper code, and adapters" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, + {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, +] [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] @@ -24,6 +34,10 @@ description = "Annotate AST trees with source code positions" category = "dev" optional = false python-versions = "*" +files = [ + {file = "asttokens-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b28ed85e254b724439afc783d4bee767f780b936c3fe8b3275332f42cf5f561"}, + {file = "asttokens-2.1.0.tar.gz", hash = "sha256:4aa76401a151c8cc572d906aad7aea2a841780834a19d780f4321c0fe1b54635"}, +] [package.dependencies] six = "*" @@ -38,6 +52,10 @@ description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=3.5" +files = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] [package.extras] dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] @@ -52,6 +70,10 @@ description = "Internationalization utilities" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, + {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, +] [package.dependencies] pytz = ">=2015.7" @@ -63,6 +85,10 @@ description = "Specifications for callback functions passed in to an API" category = "dev" optional = false python-versions = "*" +files = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] [[package]] name = "black" @@ -71,6 +97,31 @@ description = "The uncompromising code formatter." category = "dev" optional = false python-versions = ">=3.6.2" +files = [ + {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, + {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, + {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, + {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, + {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, + {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, + {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, + {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, + {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, + {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, + {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, + {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, + {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, + {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, + {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, + {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, + {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, + {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, + {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, +] [package.dependencies] click = ">=8.0.0" @@ -93,6 +144,10 @@ description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, +] [[package]] name = "cffi" @@ -101,6 +156,72 @@ description = "Foreign Function Interface for Python calling C code." category = "main" optional = false python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] [package.dependencies] pycparser = "*" @@ -112,6 +233,10 @@ description = "Universal encoding detector for Python 3" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "chardet-5.0.0-py3-none-any.whl", hash = "sha256:d3e64f022d254183001eccc5db4040520c0f23b1a3f33d6413e099eb7f126557"}, + {file = "chardet-5.0.0.tar.gz", hash = "sha256:0368df2bfd78b5fc20572bb4e9bb7fb53e2c094f60ae9993339e8671d0afb8aa"}, +] [[package]] name = "charset-normalizer" @@ -120,6 +245,10 @@ description = "The Real First Universal Charset Detector. Open, modern and activ category = "main" optional = false python-versions = ">=3.6.0" +files = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] [package.extras] unicode-backport = ["unicodedata2"] @@ -131,6 +260,10 @@ description = "Composable command line interface toolkit" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} @@ -142,6 +275,10 @@ description = "Cross-platform colored terminal text." category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] [[package]] name = "coverage" @@ -150,6 +287,49 @@ description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, + {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, + {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, + {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, + {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, + {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, + {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, + {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, + {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, + {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, + {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, + {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, + {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, +] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} @@ -164,6 +344,34 @@ description = "cryptography is a package which provides cryptographic recipes an category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:984fe150f350a3c91e84de405fe49e688aa6092b3525f407a18b9646f6612320"}, + {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:ed7b00096790213e09eb11c97cc6e2b757f15f3d2f85833cd2d3ec3fe37c1722"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bbf203f1a814007ce24bd4d51362991d5cb90ba0c177a9c08825f2cc304d871f"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554bec92ee7d1e9d10ded2f7e92a5d70c1f74ba9524947c0ba0c850c7b011828"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b52c9e5f8aa2b802d48bd693190341fae201ea51c7a167d69fc48b60e8a959"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:728f2694fa743a996d7784a6194da430f197d5c58e2f4e278612b359f455e4a2"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfb4f4dd568de1b6af9f4cda334adf7d72cf5bc052516e1b2608b683375dd95c"}, + {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5419a127426084933076132d317911e3c6eb77568a1ce23c3ac1e12d111e61e0"}, + {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9b24bcff7853ed18a63cfb0c2b008936a9554af24af2fb146e16d8e1aed75748"}, + {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:25c1d1f19729fb09d42e06b4bf9895212292cb27bb50229f5aa64d039ab29146"}, + {file = "cryptography-38.0.3-cp36-abi3-win32.whl", hash = "sha256:7f836217000342d448e1c9a342e9163149e45d5b5eca76a30e84503a5a96cab0"}, + {file = "cryptography-38.0.3-cp36-abi3-win_amd64.whl", hash = "sha256:c46837ea467ed1efea562bbeb543994c2d1f6e800785bd5a2c98bc096f5cb220"}, + {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06fc3cc7b6f6cca87bd56ec80a580c88f1da5306f505876a71c8cfa7050257dd"}, + {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:65535bc550b70bd6271984d9863a37741352b4aad6fb1b3344a54e6950249b55"}, + {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5e89468fbd2fcd733b5899333bc54d0d06c80e04cd23d8c6f3e0542358c6060b"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6ab9516b85bebe7aa83f309bacc5f44a61eeb90d0b4ec125d2d003ce41932d36"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068147f32fa662c81aebab95c74679b401b12b57494872886eb5c1139250ec5d"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:402852a0aea73833d982cabb6d0c3bb582c15483d29fb7085ef2c42bfa7e38d7"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b1b35d9d3a65542ed2e9d90115dfd16bbc027b3f07ee3304fc83580f26e43249"}, + {file = "cryptography-38.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6addc3b6d593cd980989261dc1cce38263c76954d758c3c94de51f1e010c9a50"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:be243c7e2bfcf6cc4cb350c0d5cdf15ca6383bbcb2a8ef51d3c9411a9d4386f0"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cf5eefac2b52c10398a42765bfa981ce2372cbc0457e6bf9658f41ec3c41d8"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4e269dcd9b102c5a3d72be3c45d8ce20377b8076a43cbed6f660a1afe365e436"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8d41a46251bf0634e21fac50ffd643216ccecfaf3701a063257fe0b2be1b6548"}, + {file = "cryptography-38.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:785e4056b5a8b28f05a533fab69febf5004458e20dad7e2e13a3120d8ecec75a"}, + {file = "cryptography-38.0.3.tar.gz", hash = "sha256:bfbe6ee19615b07a98b1d2287d6a6073f734735b49ee45b11324d85efc4d5cbd"}, +] [package.dependencies] cffi = ">=1.12" @@ -183,14 +391,22 @@ description = "Decorators for Humans" category = "dev" optional = false python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] [[package]] name = "django" -version = "3.2.16" +version = "3.2.18" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "Django-3.2.18-py3-none-any.whl", hash = "sha256:4d492d9024c7b3dfababf49f94511ab6a58e2c9c3c7207786f1ba4eb77750706"}, + {file = "Django-3.2.18.tar.gz", hash = "sha256:08208dfe892eb64fff073ca743b3b952311104f939e7f6dae954fe72dcc533ba"}, +] [package.dependencies] asgiref = ">=3.3.2,<4" @@ -208,6 +424,10 @@ description = "django-cors-headers is a Django application for handling the serv category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "django-cors-headers-3.13.0.tar.gz", hash = "sha256:f9dc6b4e3f611c3199700b3e5f3398c28757dcd559c2f82932687f3d0443cfdf"}, + {file = "django_cors_headers-3.13.0-py3-none-any.whl", hash = "sha256:37e42883b5f1f2295df6b4bba96eb2417a14a03270cb24b2a07f021cd4487cf4"}, +] [package.dependencies] Django = ">=3.2" @@ -219,6 +439,10 @@ description = "A package that allows you to utilize 12factor inspired environmen category = "main" optional = false python-versions = ">=3.4,<4" +files = [ + {file = "django-environ-0.8.1.tar.gz", hash = "sha256:6f0bc902b43891656b20486938cba0861dc62892784a44919170719572a534cb"}, + {file = "django_environ-0.8.1-py2.py3-none-any.whl", hash = "sha256:42593bee519a527602a467c7b682aee1a051c2597f98c45f4f4f44169ecdb6e5"}, +] [package.extras] develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] @@ -232,6 +456,10 @@ description = "A django middleware that provides one application programminginte category = "main" optional = false python-versions = "*" +files = [ + {file = "django-excel-0.0.10.tar.gz", hash = "sha256:81cd3bce8007009c30205f7085a97f2908557014900775577cab0b9a770c2bad"}, + {file = "django_excel-0.0.10-py2.py3-none-any.whl", hash = "sha256:f0297202fc460eb74657f8a9d4473921050fbe2e297765c174be9cf33d1195c7"}, +] [package.dependencies] Django = ">=1.6.1" @@ -250,6 +478,10 @@ description = "Extensions for Django" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "django-extensions-3.2.1.tar.gz", hash = "sha256:2a4f4d757be2563cd1ff7cfdf2e57468f5f931cc88b23cf82ca75717aae504a4"}, + {file = "django_extensions-3.2.1-py3-none-any.whl", hash = "sha256:421464be390289513f86cb5e18eb43e5dc1de8b4c27ba9faa3b91261b0d67e09"}, +] [package.dependencies] Django = ">=3.2" @@ -261,6 +493,10 @@ description = "Django-filter is a reusable Django application for allowing users category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "django-filter-21.1.tar.gz", hash = "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e"}, + {file = "django_filter-21.1-py3-none-any.whl", hash = "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"}, +] [package.dependencies] Django = ">=2.2" @@ -272,6 +508,10 @@ description = "Hurricane is an initiative to fit Django perfectly with Kubernete category = "main" optional = false python-versions = "~=3.8" +files = [ + {file = "django-hurricane-1.3.4.tar.gz", hash = "sha256:16fd74239adc8bba75b988859a3a28820e93f8c1232c65f251e70b790de04e92"}, + {file = "django_hurricane-1.3.4-py3-none-any.whl", hash = "sha256:6706dc95b05d07e4eb32b08b6b7f11ea0fbd3952bb915f0e3648e1df3fa599a2"}, +] [package.dependencies] asgiref = ">=3.4.0,<3.5.0" @@ -291,6 +531,10 @@ description = "Adds support for using money and currency fields in django models category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "django-money-2.1.1.tar.gz", hash = "sha256:4d06041fac5c565ad049a7f8fb4bc33de5c68047b0693efa18a9931cebffb606"}, + {file = "django_money-2.1.1-py3-none-any.whl", hash = "sha256:60a605d5b999e1756a18008dd7e0c5a860fd64c018a140c7a7675a4209ec3782"}, +] [package.dependencies] Django = ">=2.2" @@ -308,6 +552,10 @@ description = "Django multiple select field" category = "main" optional = false python-versions = "*" +files = [ + {file = "django-multiselectfield-0.1.12.tar.gz", hash = "sha256:d0a4c71568fb2332c71478ffac9f8708e01314a35cf923dfd7a191343452f9f9"}, + {file = "django_multiselectfield-0.1.12-py3-none-any.whl", hash = "sha256:c270faa7f80588214c55f2d68cbddb2add525c2aa830c216b8a198de914eb470"}, +] [package.dependencies] django = ">=1.4" @@ -319,6 +567,10 @@ description = "Recursive nesting of inline forms for Django Admin" category = "main" optional = false python-versions = "*" +files = [ + {file = "django-nested-inline-0.4.5.tar.gz", hash = "sha256:d7e51dc1ebf805df53ec7ff9b4108dfe1dfb8a7e02212700a4e467f2caafe34b"}, + {file = "django_nested_inline-0.4.5-py3-none-any.whl", hash = "sha256:fc6998e7d607c1414e25897ae1a544105ada93d63c5cff3ac9582fbf14d8ec63"}, +] [[package]] name = "django-prometheus" @@ -327,6 +579,10 @@ description = "Django middlewares to monitor your application with Prometheus.io category = "main" optional = false python-versions = "*" +files = [ + {file = "django-prometheus-2.2.0.tar.gz", hash = "sha256:240378a1307c408bd5fc85614a3a57f1ce633d4a222c9e291e2bbf325173b801"}, + {file = "django_prometheus-2.2.0-py2.py3-none-any.whl", hash = "sha256:e6616770d8820b8834762764bf1b76ec08e1b98e72a6f359d488a2e15fe3537c"}, +] [package.dependencies] prometheus-client = ">=0.7" @@ -338,6 +594,10 @@ description = "Web APIs for Django, made easy." category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, + {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, +] [package.dependencies] django = ">=2.2" @@ -350,6 +610,10 @@ description = "A Django REST framework API adapter for the JSON:API spec." category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "djangorestframework-jsonapi-5.0.0.tar.gz", hash = "sha256:090c568dc99380ead71cc378020b4cd191db2ffce9ab3e9339df80d5d82c8648"}, + {file = "djangorestframework_jsonapi-5.0.0-py2.py3-none-any.whl", hash = "sha256:f25b0d24a990690e578668b7a7a191a75162f1d9561abd773d12de331cf16673"}, +] [package.dependencies] django = ">=2.2,<4.1" @@ -368,6 +632,10 @@ description = "An implementation of lxml.xmlfile for the standard library" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"}, + {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, +] [[package]] name = "exceptiongroup" @@ -376,6 +644,10 @@ description = "Backport of PEP 654 (exception groups)" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, + {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, +] [package.extras] test = ["pytest (>=6)"] @@ -387,6 +659,10 @@ description = "Get the currently executing AST node of a frame, and other inform category = "dev" optional = false python-versions = "*" +files = [ + {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"}, + {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"}, +] [package.extras] tests = ["asttokens", "littleutils", "pytest", "rich"] @@ -398,6 +674,10 @@ description = "A versatile test fixtures replacement based on thoughtbot's facto category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, + {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, +] [package.dependencies] Faker = ">=0.7.0" @@ -413,6 +693,10 @@ description = "Faker is a Python package that generates fake data for you." category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "Faker-15.3.1-py3-none-any.whl", hash = "sha256:4a3465624515a6807e8aa7e8eeb85bdd86a2fa53de4e258892dd6be95362462e"}, + {file = "Faker-15.3.1.tar.gz", hash = "sha256:b9dd2fd9a9ac68a4e0c5040cd9e9bfaa099fa8dd15bae5f01f224a45431818d5"}, +] [package.dependencies] python-dateutil = ">=2.4" @@ -424,6 +708,10 @@ description = "colorful TAB completion for Python prompt" category = "dev" optional = false python-versions = "*" +files = [ + {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, + {file = "fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272"}, +] [package.dependencies] pyreadline = {version = "*", markers = "platform_system == \"Windows\""} @@ -436,6 +724,10 @@ description = "A fast native implementation of diff algorithm with a pure python category = "dev" optional = false python-versions = "*" +files = [ + {file = "fastdiff-0.3.0-py2.py3-none-any.whl", hash = "sha256:ca5f61f6ddf5a1564ddfd98132ad28e7abe4a88a638a8b014a2214f71e5918ec"}, + {file = "fastdiff-0.3.0.tar.gz", hash = "sha256:4dfa09c47832a8c040acda3f1f55fc0ab4d666f0e14e6951e6da78d59acd945a"}, +] [package.dependencies] wasmer = ">=1.0.0" @@ -448,6 +740,10 @@ description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, +] [package.dependencies] mccabe = ">=0.6.0,<0.7.0" @@ -461,6 +757,9 @@ description = "A flake8 extension that checks for blind except: statements" category = "dev" optional = false python-versions = "*" +files = [ + {file = "flake8-blind-except-0.2.1.tar.gz", hash = "sha256:f25a575a9dcb3eeb3c760bf9c22db60b8b5a23120224ed1faa9a43f75dd7dd16"}, +] [[package]] name = "flake8-debugger" @@ -469,6 +768,10 @@ description = "ipdb/pdb statement checker plugin for flake8" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "flake8-debugger-4.1.2.tar.gz", hash = "sha256:52b002560941e36d9bf806fca2523dc7fb8560a295d5f1a6e15ac2ded7a73840"}, + {file = "flake8_debugger-4.1.2-py3-none-any.whl", hash = "sha256:0a5e55aeddcc81da631ad9c8c366e7318998f83ff00985a49e6b3ecf61e571bf"}, +] [package.dependencies] flake8 = ">=3.0" @@ -481,6 +784,10 @@ description = "Warns about deprecated method calls." category = "dev" optional = false python-versions = "*" +files = [ + {file = "flake8-deprecated-1.3.tar.gz", hash = "sha256:9fa5a0c5c81fb3b34c53a0e4f16cd3f0a3395078cfd4988011cbab5fb0afa7f7"}, + {file = "flake8_deprecated-1.3-py2.py3-none-any.whl", hash = "sha256:211951854837ced9ec997a75c6e5b957f3536a735538ee0620b76539fd3706cd"}, +] [package.dependencies] flake8 = ">=3.0.0" @@ -492,6 +799,10 @@ description = "Extension for flake8 which uses pydocstyle to check docstrings" category = "dev" optional = false python-versions = "*" +files = [ + {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, + {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, +] [package.dependencies] flake8 = ">=3" @@ -504,6 +815,10 @@ description = "flake8 plugin that integrates isort ." category = "dev" optional = false python-versions = "*" +files = [ + {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, + {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, +] [package.dependencies] flake8 = ">=3.2.1,<5" @@ -520,6 +835,10 @@ description = "string format checker, plugin for flake8" category = "dev" optional = false python-versions = "*" +files = [ + {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, + {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, +] [package.dependencies] flake8 = "*" @@ -531,6 +850,10 @@ description = "Let your Python tests travel through time" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, + {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, +] [package.dependencies] python-dateutil = ">=2.7" @@ -542,6 +865,10 @@ description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] [[package]] name = "importlib-metadata" @@ -550,6 +877,10 @@ description = "Read metadata from Python packages" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, + {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, +] [package.dependencies] zipp = ">=0.5" @@ -566,6 +897,10 @@ description = "A port of Ruby on Rails inflector to Python" category = "main" optional = false python-versions = ">=3.5" +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] [[package]] name = "iniconfig" @@ -574,6 +909,10 @@ description = "iniconfig: brain-dead simple config-ini parsing" category = "dev" optional = false python-versions = "*" +files = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] [[package]] name = "ipdb" @@ -582,6 +921,9 @@ description = "IPython-enabled pdb" category = "dev" optional = false python-versions = ">=2.7" +files = [ + {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, +] [package.dependencies] decorator = {version = "*", markers = "python_version > \"3.6\""} @@ -596,6 +938,10 @@ description = "IPython: Productive Interactive Computing" category = "dev" optional = false python-versions = ">=3.8" +files = [ + {file = "ipython-8.6.0-py3-none-any.whl", hash = "sha256:91ef03016bcf72dd17190f863476e7c799c6126ec7e8be97719d1bc9a78a59a4"}, + {file = "ipython-8.6.0.tar.gz", hash = "sha256:7c959e3dedbf7ed81f9b9d8833df252c430610e2a4a6464ec13cd20975ce20a5"}, +] [package.dependencies] appnope = {version = "*", markers = "sys_platform == \"darwin\""} @@ -631,6 +977,10 @@ description = "A Python utility / library to sort Python imports." category = "dev" optional = false python-versions = ">=3.6.1,<4.0" +files = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] [package.extras] colors = ["colorama (>=0.4.3,<0.5.0)"] @@ -645,6 +995,10 @@ description = "An autocompletion tool for Python that can be used for text edito category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, + {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, +] [package.dependencies] parso = ">=0.8.0,<0.9.0" @@ -660,6 +1014,10 @@ description = "JOSE protocol implementation in Python" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "josepy-1.13.0-py2.py3-none-any.whl", hash = "sha256:6f64eb35186aaa1776b7a1768651b1c616cab7f9685f9660bffc6491074a5390"}, + {file = "josepy-1.13.0.tar.gz", hash = "sha256:8931daf38f8a4c85274a0e8b7cb25addfd8d1f28f9fb8fbed053dd51aec75dc9"}, +] [package.dependencies] cryptography = ">=1.5" @@ -678,6 +1036,10 @@ description = "Load me later. A lazy plugin management system." category = "main" optional = false python-versions = "*" +files = [ + {file = "lml-0.1.0-py2.py3-none-any.whl", hash = "sha256:ec06e850019942a485639c8c2a26bdb99eae24505bee7492b649df98a0bed101"}, + {file = "lml-0.1.0.tar.gz", hash = "sha256:57a085a29bb7991d70d41c6c3144c560a8e35b4c1030ffb36d85fa058773bcc5"}, +] [[package]] name = "lxml" @@ -686,6 +1048,78 @@ description = "Powerful and Pythonic XML processing library combining libxml2/li category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" +files = [ + {file = "lxml-4.9.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed"}, + {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc"}, + {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc"}, + {file = "lxml-4.9.1-cp27-cp27m-win32.whl", hash = "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3"}, + {file = "lxml-4.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627"}, + {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84"}, + {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8"}, + {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8"}, + {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d"}, + {file = "lxml-4.9.1-cp310-cp310-win32.whl", hash = "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7"}, + {file = "lxml-4.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b"}, + {file = "lxml-4.9.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d"}, + {file = "lxml-4.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3"}, + {file = "lxml-4.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29"}, + {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d"}, + {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318"}, + {file = "lxml-4.9.1-cp35-cp35m-win32.whl", hash = "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7"}, + {file = "lxml-4.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4"}, + {file = "lxml-4.9.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf"}, + {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3"}, + {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391"}, + {file = "lxml-4.9.1-cp36-cp36m-win32.whl", hash = "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e"}, + {file = "lxml-4.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7"}, + {file = "lxml-4.9.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca"}, + {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785"}, + {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785"}, + {file = "lxml-4.9.1-cp37-cp37m-win32.whl", hash = "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a"}, + {file = "lxml-4.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e"}, + {file = "lxml-4.9.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715"}, + {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036"}, + {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387"}, + {file = "lxml-4.9.1-cp38-cp38-win32.whl", hash = "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94"}, + {file = "lxml-4.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345"}, + {file = "lxml-4.9.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000"}, + {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25"}, + {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd"}, + {file = "lxml-4.9.1-cp39-cp39-win32.whl", hash = "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb"}, + {file = "lxml-4.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73"}, + {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c"}, + {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"}, + {file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"}, +] [package.extras] cssselect = ["cssselect (>=0.7)"] @@ -700,6 +1134,10 @@ description = "Inline Matplotlib backend for Jupyter" category = "dev" optional = false python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] [package.dependencies] traitlets = "*" @@ -711,6 +1149,10 @@ description = "McCabe checker, plugin for flake8" category = "dev" optional = false python-versions = "*" +files = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] [[package]] name = "mozilla-django-oidc" @@ -719,6 +1161,10 @@ description = "A lightweight authentication and access management library for in category = "main" optional = false python-versions = "*" +files = [ + {file = "mozilla-django-oidc-2.0.0.tar.gz", hash = "sha256:a8b2f27c69c122d2f4d801c3759761d33debf06ae9dabbab8aed82887bba3bb8"}, + {file = "mozilla_django_oidc-2.0.0-py2.py3-none-any.whl", hash = "sha256:53c39755b667e8c5923b1dffc3c29673198d03aa107aa42ac86b8a38b4720c25"}, +] [package.dependencies] cryptography = "*" @@ -733,6 +1179,10 @@ description = "Experimental type system extensions for programs checked with the category = "dev" optional = false python-versions = "*" +files = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] [[package]] name = "openpyxl" @@ -741,6 +1191,10 @@ description = "A Python library to read/write Excel 2010 xlsx/xlsm files" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "openpyxl-3.0.10-py2.py3-none-any.whl", hash = "sha256:0ab6d25d01799f97a9464630abacbb34aafecdcaa0ef3cba6d6b3499867d0355"}, + {file = "openpyxl-3.0.10.tar.gz", hash = "sha256:e47805627aebcf860edb4edf7987b1309c1b3632f3750538ed962bbcc3bd7449"}, +] [package.dependencies] et-xmlfile = "*" @@ -752,6 +1206,10 @@ description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] [package.dependencies] pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" @@ -763,6 +1221,10 @@ description = "A Python Parser" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] [package.extras] qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] @@ -775,6 +1237,10 @@ description = "Utility library for gitignore style pattern matching of file path category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, + {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, +] [[package]] name = "pdbpp" @@ -783,6 +1249,10 @@ description = "pdb++, a drop-in replacement for pdb" category = "dev" optional = false python-versions = "*" +files = [ + {file = "pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1"}, + {file = "pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5"}, +] [package.dependencies] fancycompleter = ">=0.8" @@ -800,6 +1270,10 @@ description = "Pexpect allows easy control of interactive console applications." category = "dev" optional = false python-versions = "*" +files = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] [package.dependencies] ptyprocess = ">=0.5" @@ -811,6 +1285,10 @@ description = "Tiny 'shelve'-like database with concurrency support" category = "dev" optional = false python-versions = "*" +files = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] [[package]] name = "pika" @@ -819,6 +1297,10 @@ description = "Pika Python AMQP Client Library" category = "main" optional = false python-versions = "*" +files = [ + {file = "pika-1.1.0-py2.py3-none-any.whl", hash = "sha256:4e1a1a6585a41b2341992ec32aadb7a919d649eb82904fd8e4a4e0871c8cf3af"}, + {file = "pika-1.1.0.tar.gz", hash = "sha256:9fa76ba4b65034b878b2b8de90ff8660a59d925b087c5bb88f8fdbb4b64a1dbf"}, +] [package.extras] tornado = ["tornado"] @@ -831,6 +1313,10 @@ description = "A small Python package for determining appropriate platform-speci category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, + {file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, +] [package.extras] docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] @@ -843,6 +1329,10 @@ description = "plugin and hook calling mechanisms for python" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] [package.extras] dev = ["pre-commit", "tox"] @@ -855,6 +1345,10 @@ description = "Python client for the Prometheus monitoring system." category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "prometheus_client-0.15.0-py3-none-any.whl", hash = "sha256:db7c05cbd13a0f79975592d112320f2605a325969b270a94b71dcabc47b931d2"}, + {file = "prometheus_client-0.15.0.tar.gz", hash = "sha256:be26aa452490cfcf6da953f9436e95a9f2b4d578ca80094b4458930e5f584ab1"}, +] [package.extras] twisted = ["twisted"] @@ -866,6 +1360,10 @@ description = "Library for building powerful interactive command lines in Python category = "dev" optional = false python-versions = ">=3.6.2" +files = [ + {file = "prompt_toolkit-3.0.32-py3-none-any.whl", hash = "sha256:24becda58d49ceac4dc26232eb179ef2b21f133fecda7eed6018d341766ed76e"}, + {file = "prompt_toolkit-3.0.32.tar.gz", hash = "sha256:e7f2129cba4ff3b3656bbdda0e74ee00d2f874a8bcdb9dd16f5fec7b3e173cae"}, +] [package.dependencies] wcwidth = "*" @@ -877,6 +1375,79 @@ description = "psycopg2 - Python-PostgreSQL Database Adapter" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:0775d6252ccb22b15da3b5d7adbbf8cfe284916b14b6dc0ff503a23edb01ee85"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3520d7af1ebc838cc6084a3281145d5cd5bdd43fdef139e6db5af01b92596cb7"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cbc554ba47ecca8cd3396ddaca85e1ecfe3e48dd57dc5e415e59551affe568e"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:5d28ecdf191db558d0c07d0f16524ee9d67896edf2b7990eea800abeb23ebd61"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05b3d479425e047c848b9782cd7aac9c6727ce23181eb9647baf64ffdfc3da41"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e491e6489a6cb1d079df8eaa15957c277fdedb102b6a68cfbf40c4994412fd0"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:9e32cedc389bcb76d9f24ea8a012b3cb8385ee362ea437e1d012ffaed106c17d"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"}, + {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_10_9_universal2.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:426c2ae999135d64e6a18849a7d1ad0e1bd007277e4a8f4752eaa40a96b550ff"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:af0516e1711995cb08dc19bbd05bec7dbdebf4185f68870595156718d237df3e"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e72c91bda9880f097c8aa3601a2c0de6c708763ba8128006151f496ca9065935"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"}, + {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:56b2957a145f816726b109ee3d4e6822c23f919a7d91af5a94593723ed667835"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:70831e03bd53702c941da1a1ad36c17d825a24fbb26857b40913d58df82ec18b"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dbc332beaf8492b5731229a881807cd7b91b50dbbbaf7fe2faf46942eda64a24"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:95076399ec3b27a8f7fa1cc9a83417b1c920d55cf7a97f718a94efbb96c7f503"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-win32.whl", hash = "sha256:3fc33295cfccad697a97a76dec3f1e94ad848b7b163c3228c1636977966b51e2"}, + {file = "psycopg2_binary-2.9.5-cp36-cp36m-win_amd64.whl", hash = "sha256:02551647542f2bf89073d129c73c05a25c372fc0a49aa50e0de65c3c143d8bd0"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:63e318dbe52709ed10d516a356f22a635e07a2e34c68145484ed96a19b0c4c68"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e518a0911c50f60313cb9e74a169a65b5d293770db4770ebf004245f24b5c5"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:68d81a2fe184030aa0c5c11e518292e15d342a667184d91e30644c9d533e53e1"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:7ee3095d02d6f38bd7d9a5358fcc9ea78fcdb7176921528dd709cc63f40184f5"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:46512486be6fbceef51d7660dec017394ba3e170299d1dc30928cbedebbf103a"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b911dfb727e247340d36ae20c4b9259e4a64013ab9888ccb3cbba69b77fd9636"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:422e3d43b47ac20141bc84b3d342eead8d8099a62881a501e97d15f6addabfe9"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-win32.whl", hash = "sha256:b8104f709590fff72af801e916817560dbe1698028cd0afe5a52d75ceb1fce5f"}, + {file = "psycopg2_binary-2.9.5-cp37-cp37m-win_amd64.whl", hash = "sha256:7b3751857da3e224f5629400736a7b11e940b5da5f95fa631d86219a1beaafec"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:043a9fd45a03858ff72364b4b75090679bd875ee44df9c0613dc862ca6b98460"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9ffdc51001136b699f9563b1c74cc1f8c07f66ef7219beb6417a4c8aaa896c28"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc85b3777068ed30aff8242be2813038a929f2084f69e43ef869daddae50f6ee"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:7d07f552d1e412f4b4e64ce386d4c777a41da3b33f7098b6219012ba534fb2c2"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a0adef094c49f242122bb145c3c8af442070dc0e4312db17e49058c1702606d4"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00475004e5ed3e3bf5e056d66e5dcdf41a0dc62efcd57997acd9135c40a08a50"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7d88db096fa19d94f433420eaaf9f3c45382da2dd014b93e4bf3215639047c16"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:902844f9c4fb19b17dfa84d9e2ca053d4a4ba265723d62ea5c9c26b38e0aa1e6"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-win32.whl", hash = "sha256:4e7904d1920c0c89105c0517dc7e3f5c20fb4e56ba9cdef13048db76947f1d79"}, + {file = "psycopg2_binary-2.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:a36a0e791805aa136e9cbd0ffa040d09adec8610453ee8a753f23481a0057af5"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9c38d3869238e9d3409239bc05bc27d6b7c99c2a460ea337d2814b35fb4fea1b"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6527c8efa5226a9e787507652dd5ba97b62d29b53c371a85cd13f957fe4d42"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59137cdb970249ae60be2a49774c6dfb015bd0403f05af1fe61862e9626642d"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:d4c7b3a31502184e856df1f7bbb2c3735a05a8ce0ade34c5277e1577738a5c91"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b9a794cef1d9c1772b94a72eec6da144c18e18041d294a9ab47669bc77a80c1d"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5e65c6ac0ae4bf5bef1667029f81010b6017795dcb817ba5c7b8a8d61fab76f"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:74eddec4537ab1f701a1647214734bc52cee2794df748f6ae5908e00771f180a"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:01ad49d68dd8c5362e4bfb4158f2896dc6e0c02e87b8a3770fc003459f1a4425"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-win32.whl", hash = "sha256:937880290775033a743f4836aa253087b85e62784b63fd099ee725d567a48aa1"}, + {file = "psycopg2_binary-2.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:484405b883630f3e74ed32041a87456c5e0e63a8e3429aa93e8714c366d62bd1"}, +] [[package]] name = "ptyprocess" @@ -885,6 +1456,10 @@ description = "Run a subprocess in a pseudo terminal" category = "dev" optional = false python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] [[package]] name = "pure-eval" @@ -893,6 +1468,10 @@ description = "Safely evaluate AST nodes without side effects" category = "dev" optional = false python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] [package.extras] tests = ["pytest"] @@ -904,6 +1483,10 @@ description = "Provides Currency and Money classes for use in your Python code." category = "main" optional = false python-versions = "*" +files = [ + {file = "py-moneyed-1.2.tar.gz", hash = "sha256:d745a52819604f42b3666f9b2504b71c27c1645d6d5027d95ec5ed1f28ca86e3"}, + {file = "py_moneyed-1.2-py2.py3-none-any.whl", hash = "sha256:c6131c7b7c1f8503552afe44d15c343ea50282d1d9e6fa8b3f1bd2affc1dae1e"}, +] [package.dependencies] babel = ">=2.8.0" @@ -918,6 +1501,10 @@ description = "Python style guide checker" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, +] [[package]] name = "pycparser" @@ -926,6 +1513,10 @@ description = "C parser in Python" category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] [[package]] name = "pydocstyle" @@ -934,7 +1525,11 @@ description = "Python docstring style checker" category = "dev" optional = false python-versions = ">=3.6" - +files = [ + {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, + {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, +] + [package.dependencies] snowballstemmer = "*" @@ -948,6 +1543,10 @@ description = "A wrapper library that provides one API to read, manipulate and w category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "pyexcel-0.7.0-py2.py3-none-any.whl", hash = "sha256:ddc6904512bfa2ecda509fb3b58229bb30db14498632fd9e7a5ba7bbfb02ed1b"}, + {file = "pyexcel-0.7.0.tar.gz", hash = "sha256:fbf0eee5d93b96cef6f19a9f00703f22c0a64f19728d91b95428009a52129709"}, +] [package.dependencies] chardet = "*" @@ -967,6 +1566,10 @@ description = "A Python package to create/manipulate OpenDocumentFormat files" category = "main" optional = false python-versions = "*" +files = [ + {file = "pyexcel-ezodf-0.3.4.tar.gz", hash = "sha256:972eeea9b0e4bab60dfc5cdcb7378cc7ba5e070a0b7282746c0182c5de011ff1"}, + {file = "pyexcel_ezodf-0.3.4-py2.py3-none-any.whl", hash = "sha256:a74ac7636a015fff31d35c5350dc5ad347ba98ecb453de4dbcbb9a9168434e8c"}, +] [package.dependencies] lxml = "*" @@ -978,6 +1581,10 @@ description = "A python library to read and write structured data in csv, zipped category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "pyexcel-io-0.6.6.tar.gz", hash = "sha256:f6084bf1afa5fbf4c61cf7df44370fa513821af188b02e3e19b5efb66d8a969f"}, + {file = "pyexcel_io-0.6.6-py2.py3-none-any.whl", hash = "sha256:19ff1d599a8a6c0982e4181ef86aa50e1f8d231410fa7e0e204d62e37551c1d6"}, +] [package.dependencies] lml = ">=0.0.4" @@ -994,6 +1601,10 @@ description = "A wrapper library to read, manipulate and write data in ods forma category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "pyexcel-ods3-0.6.1.tar.gz", hash = "sha256:53740fc9bc6e91e43cdc0ee4f557bb3b252d8493d34f2c11d26a93c53cfebc2e"}, + {file = "pyexcel_ods3-0.6.1-py3-none-any.whl", hash = "sha256:ca61d139879349a5d4b0a241add6504474c59fa280d1804b76f56ee4ba30eb8b"}, +] [package.dependencies] lxml = "*" @@ -1007,6 +1618,10 @@ description = "A generic request and response interface for pyexcel web extensio category = "main" optional = false python-versions = "*" +files = [ + {file = "pyexcel-webio-0.1.4.tar.gz", hash = "sha256:039538f1b35351f1632891dde29ef4d7fba744e217678ebb5a501336e28ca265"}, + {file = "pyexcel_webio-0.1.4-py2.py3-none-any.whl", hash = "sha256:3583cf7dcddb747520a8a90e93cf07b0584878b56b3c41c46d132b458a6cfd00"}, +] [package.dependencies] pyexcel = ">=0.5.6" @@ -1018,6 +1633,10 @@ description = "A wrapper library to read, manipulate and write data in xlsx and category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "pyexcel-xlsx-0.6.0.tar.gz", hash = "sha256:55754f764252461aca6871db203f4bd1370ec877828e305e6be1de5f9aa6a79d"}, + {file = "pyexcel_xlsx-0.6.0-py2.py3-none-any.whl", hash = "sha256:16530f96a77c97ebcba7941517d2756ac52d3ce2903d81eecd7f300778d5242a"}, +] [package.dependencies] openpyxl = ">=2.6.1" @@ -1030,6 +1649,10 @@ description = "passive checker of Python programs" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] [[package]] name = "pygments" @@ -1038,6 +1661,10 @@ description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, +] [package.extras] plugins = ["importlib-metadata"] @@ -1049,6 +1676,10 @@ description = "Python wrapper module around the OpenSSL library" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "pyOpenSSL-22.1.0-py3-none-any.whl", hash = "sha256:b28437c9773bb6c6958628cf9c3bebe585de661dba6f63df17111966363dd15e"}, + {file = "pyOpenSSL-22.1.0.tar.gz", hash = "sha256:7a83b7b272dd595222d672f5ce29aa030f1fb837630ef229f62e72e395ce8968"}, +] [package.dependencies] cryptography = ">=38.0.0,<39" @@ -1064,6 +1695,10 @@ description = "pyparsing module - Classes and methods to define and execute pars category = "dev" optional = false python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] [package.extras] diagrams = ["jinja2", "railroad-diagrams"] @@ -1075,6 +1710,9 @@ description = "A python implmementation of GNU readline." category = "dev" optional = false python-versions = "*" +files = [ + {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, +] [[package]] name = "pyrepl" @@ -1083,6 +1721,9 @@ description = "A library for building flexible command line interfaces" category = "dev" optional = false python-versions = "*" +files = [ + {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, +] [[package]] name = "pytest" @@ -1091,6 +1732,10 @@ description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, + {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, +] [package.dependencies] attrs = ">=19.2.0" @@ -1111,6 +1756,10 @@ description = "Pytest plugin for measuring coverage." category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, +] [package.dependencies] coverage = {version = ">=5.2.1", extras = ["toml"]} @@ -1126,6 +1775,10 @@ description = "A Django plugin for pytest." category = "dev" optional = false python-versions = ">=3.5" +files = [ + {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, + {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, +] [package.dependencies] pytest = ">=5.4.0" @@ -1141,6 +1794,9 @@ description = "py.test plugin that allows you to add environment variables." category = "dev" optional = false python-versions = "*" +files = [ + {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"}, +] [package.dependencies] pytest = ">=2.6.0" @@ -1152,6 +1808,10 @@ description = "Factory Boy support for pytest." category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "pytest-factoryboy-2.1.0.tar.gz", hash = "sha256:23bc562ab32cc39eddfbbbf70e618a1b30e834a4cfa451c4bedc36216f0a7b19"}, + {file = "pytest_factoryboy-2.1.0-py3-none-any.whl", hash = "sha256:10c02d2736cb52c7af28065db9617e7f50634e95eaa07eeb9a007026aa3dc0a8"}, +] [package.dependencies] factory-boy = ">=2.10.0" @@ -1165,6 +1825,10 @@ description = "Wrap tests with fixtures in freeze_time" category = "dev" optional = false python-versions = "*" +files = [ + {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"}, + {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"}, +] [package.dependencies] freezegun = ">0.3" @@ -1177,6 +1841,10 @@ description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, + {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, +] [package.dependencies] pytest = ">=5.0" @@ -1191,6 +1859,10 @@ description = "Pytest plugin to randomly order tests and control random.seed." category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-randomly-3.12.0.tar.gz", hash = "sha256:d60c2db71ac319aee0fc6c4110a7597d611a8b94a5590918bfa8583f00caccb2"}, + {file = "pytest_randomly-3.12.0-py3-none-any.whl", hash = "sha256:f4f2e803daf5d1ba036cc22bf4fe9dbbf99389ec56b00e5cba732fb5c1d07fdd"}, +] [package.dependencies] importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} @@ -1203,6 +1875,10 @@ description = "Extensions to the standard Python datetime module" category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] [package.dependencies] six = ">=1.5" @@ -1214,6 +1890,10 @@ description = "Library for communicating with a Redmine project management appli category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "python-redmine-2.3.0.tar.gz", hash = "sha256:96889d1ae59b5830337e2c2fff1e2ce54103e52bbb632bd7c648f7d2d0274d25"}, + {file = "python_redmine-2.3.0-py2.py3-none-any.whl", hash = "sha256:502680473a3f9b7a001f788969c62081023afa83d896c0a54963aed3cf198b89"}, +] [package.dependencies] requests = ">=2.23.0" @@ -1225,6 +1905,10 @@ description = "World timezone definitions, modern and historical" category = "main" optional = false python-versions = "*" +files = [ + {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, + {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, +] [[package]] name = "requests" @@ -1233,6 +1917,10 @@ description = "Python HTTP for Humans." category = "main" optional = false python-versions = ">=3.7, <4" +files = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] [package.dependencies] certifi = ">=2017.4.17" @@ -1251,6 +1939,10 @@ description = "Mock out responses from the requests package" category = "dev" optional = false python-versions = "*" +files = [ + {file = "requests-mock-1.9.3.tar.gz", hash = "sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba"}, + {file = "requests_mock-1.9.3-py2.py3-none-any.whl", hash = "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970"}, +] [package.dependencies] requests = ">=2.3,<3" @@ -1267,6 +1959,10 @@ description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false python-versions = "*" +files = [ + {file = "sentry-sdk-1.14.0.tar.gz", hash = "sha256:273fe05adf052b40fd19f6d4b9a5556316807246bd817e5e3482930730726bb0"}, + {file = "sentry_sdk-1.14.0-py2.py3-none-any.whl", hash = "sha256:72c00322217d813cf493fe76590b23a757e063ff62fec59299f4af7201dd4448"}, +] [package.dependencies] certifi = "*" @@ -1302,6 +1998,10 @@ description = "Easily download, build, install, upgrade, and uninstall Python pa category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "setuptools-65.5.1-py3-none-any.whl", hash = "sha256:d0b9a8433464d5800cbe05094acf5c6d52a91bfac9b52bcfc4d41382be5d5d31"}, + {file = "setuptools-65.5.1.tar.gz", hash = "sha256:e197a19aa8ec9722928f2206f8de752def0e4c9fc6953527360d1c36d94ddb2f"}, +] [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] @@ -1315,6 +2015,10 @@ description = "Python 2 and 3 compatibility utilities" category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] [[package]] name = "snapshottest" @@ -1323,6 +2027,10 @@ description = "Snapshot testing for pytest, unittest, Django, and Nose" category = "dev" optional = false python-versions = "*" +files = [ + {file = "snapshottest-0.6.0-py2.py3-none-any.whl", hash = "sha256:9b177cffe0870c589df8ddbee0a770149c5474b251955bdbde58b7f32a4ec429"}, + {file = "snapshottest-0.6.0.tar.gz", hash = "sha256:bbcaf81d92d8e330042e5c928e13d9f035e99e91b314fe55fda949c2f17b653c"}, +] [package.dependencies] fastdiff = ">=0.1.4,<1" @@ -1341,6 +2049,10 @@ description = "This package provides 29 stemmers for 28 languages generated from category = "dev" optional = false python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] [[package]] name = "sqlparse" @@ -1349,6 +2061,10 @@ description = "A non-validating SQL parser." category = "main" optional = false python-versions = ">=3.5" +files = [ + {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, + {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, +] [[package]] name = "stack-data" @@ -1357,6 +2073,10 @@ description = "Extract data from python stack frames and tracebacks for informat category = "dev" optional = false python-versions = "*" +files = [ + {file = "stack_data-0.6.0-py3-none-any.whl", hash = "sha256:b92d206ef355a367d14316b786ab41cb99eb453a21f2cb216a4204625ff7bc07"}, + {file = "stack_data-0.6.0.tar.gz", hash = "sha256:8e515439f818efaa251036af72d89e4026e2b03993f3453c000b200fb4f2d6aa"}, +] [package.dependencies] asttokens = ">=2.1.0" @@ -1373,6 +2093,10 @@ description = "ANSI color formatting for output in terminal" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "termcolor-2.1.0-py3-none-any.whl", hash = "sha256:91dd04fdf661b89d7169cefd35f609b19ca931eb033687eaa647cef1ff177c49"}, + {file = "termcolor-2.1.0.tar.gz", hash = "sha256:b80df54667ce4f48c03fe35df194f052dc27a541ebbf2544e4d6b47b5d6949c4"}, +] [package.extras] tests = ["pytest", "pytest-cov"] @@ -1384,6 +2108,10 @@ description = "A collection of helpers and mock objects for unit tests and doc t category = "dev" optional = false python-versions = "*" +files = [ + {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"}, + {file = "testfixtures-6.18.5.tar.gz", hash = "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84"}, +] [package.extras] build = ["setuptools-git", "twine", "wheel"] @@ -1397,6 +2125,10 @@ description = "module for creating simple ASCII tables" category = "main" optional = false python-versions = "*" +files = [ + {file = "texttable-1.6.4-py2.py3-none-any.whl", hash = "sha256:dd2b0eaebb2a9e167d1cefedab4700e5dcbdb076114eed30b58b97ed6b37d6f2"}, + {file = "texttable-1.6.4.tar.gz", hash = "sha256:42ee7b9e15f7b225747c3fa08f43c5d6c83bc899f80ff9bae9319334824076e9"}, +] [[package]] name = "toml" @@ -1405,6 +2137,10 @@ description = "Python Library for Tom's Obvious, Minimal Language" category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] [[package]] name = "tomli" @@ -1413,6 +2149,10 @@ description = "A lil' TOML parser" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] [[package]] name = "tornado" @@ -1421,6 +2161,19 @@ description = "Tornado is a Python web framework and asynchronous networking lib category = "main" optional = false python-versions = ">= 3.7" +files = [ + {file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"}, + {file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a2f5999215a3a06a4fc218026cd84c61b8b2b40ac5296a6db1f1451ef04c1e"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"}, + {file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"}, + {file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"}, + {file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"}, +] [[package]] name = "traitlets" @@ -1429,6 +2182,10 @@ description = "" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "traitlets-5.5.0-py3-none-any.whl", hash = "sha256:1201b2c9f76097195989cdf7f65db9897593b0dfd69e4ac96016661bb6f0d30f"}, + {file = "traitlets-5.5.0.tar.gz", hash = "sha256:b122f9ff2f2f6c1709dab289a05555be011c87828e911c0cf4074b85cb780a79"}, +] [package.extras] docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] @@ -1441,6 +2198,10 @@ description = "Backported and Experimental Type Hints for Python 3.7+" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] [[package]] name = "urllib3" @@ -1449,6 +2210,10 @@ description = "HTTP library with thread-safe connection pooling, file post, and category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" +files = [ + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, +] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] @@ -1462,6 +2227,22 @@ description = "Python extension to run WebAssembly binaries" category = "dev" optional = false python-versions = "*" +files = [ + {file = "wasmer-1.1.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:c2af4b907ae2dabcac41e316e811d5937c93adf1f8b05c5d49427f8ce0f37630"}, + {file = "wasmer-1.1.0-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:ab1ae980021e5ec0bf0c6cdd3b979b1d15a5f3eb2b8a32da8dcb1156e4a1e484"}, + {file = "wasmer-1.1.0-cp310-none-win_amd64.whl", hash = "sha256:d0d93aec6215893d33e803ef0a8d37bf948c585dd80ba0e23a83fafee820bc03"}, + {file = "wasmer-1.1.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:1e63d16bd6e2e2272d8721647831de5c537e0bb08002ee6d7abf167ec02d5178"}, + {file = "wasmer-1.1.0-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:85e6a5bf44853e8e6a12e947ee3412da9e84f7ce49fc165ba5dbd293e9c5c405"}, + {file = "wasmer-1.1.0-cp37-none-win_amd64.whl", hash = "sha256:a182a6eca9b46d895b4985fc822fab8da3d2f84fab74ca27e55a7430a7fcf336"}, + {file = "wasmer-1.1.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:214d9a3cfb577ea9449eb2b5f13adceae34c55365e4c3d930066beb86a7f67bc"}, + {file = "wasmer-1.1.0-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:b9e5605552bd7d2bc6337519b176defe83bc69b98abf3caaaefa4f7ec231d18a"}, + {file = "wasmer-1.1.0-cp38-none-win_amd64.whl", hash = "sha256:20b5190112e2e94a8947967f2bc683c9685855d0f34130d8434c87a55216a3bd"}, + {file = "wasmer-1.1.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:ee442f0970f40ec5e32011c92fd753fb2061da0faa13de13fafc730c31be34e3"}, + {file = "wasmer-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa112198b743cff2e391230436813fb4b244a24443e37866522b7197e3a034da"}, + {file = "wasmer-1.1.0-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:c0b37117f6d3ff51ee96431c7d224d99799b08d174e30fcd0fcd7e2e3cb8140c"}, + {file = "wasmer-1.1.0-cp39-none-win_amd64.whl", hash = "sha256:a0a4730ec4907a4cb0d9d4a77ea2608c2c814f22a22b73fc80be0f110e014836"}, + {file = "wasmer-1.1.0-py3-none-any.whl", hash = "sha256:2caf8c67feae9cd4246421551036917811c446da4f27ad4c989521ef42751931"}, +] [[package]] name = "wasmer-compiler-cranelift" @@ -1470,6 +2251,22 @@ description = "The Cranelift compiler for the `wasmer` package (to compile WebAs category = "dev" optional = false python-versions = "*" +files = [ + {file = "wasmer_compiler_cranelift-1.1.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:9869910179f39696a020edc5689f7759257ac1cce569a7a0fcf340c59788baad"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:405546ee864ac158a4107f374dfbb1c8d6cfb189829bdcd13050143a4bd98f28"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp310-none-win_amd64.whl", hash = "sha256:bdf75af9ef082e6aeb752550f694273340ece970b65099e0746db0f972760d11"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:7d9c782b7721789b16e303b7e70c59df370896dd62b77e2779e3a44b4e1aa20c"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:ff7dd5bd69030b63521c24583bf0f5457cd2580237340b91ce35370f72a4a1cc"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp37-none-win_amd64.whl", hash = "sha256:447285402e366a34667a674db70458c491acd6940b797c175c0b0027f48e64bb"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:55a524985179f6b7b88ac973e8fac5a2574d3b125a966fba75fedd5a2525e484"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:bd03db5a916ead51b442c66acad38847dfe127cf90b2019b1680f1920c4f8d06"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp38-none-win_amd64.whl", hash = "sha256:157d87cbd1d04adbad55b50cb4bedc28e444caf74797fd96df17390667e58699"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:ff25fc99ebafa04a6c271d08a90d17b927930e3019a2b333c7cfb48ba32c6f71"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9697ae082317a56776df8ff7df8c922eac38488ef38d3478fe5f0ca144c185ab"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:2a4349b1ddd727bd46bc5ede741839dcfc5153c52f064a83998c4150d5d4a85c"}, + {file = "wasmer_compiler_cranelift-1.1.0-cp39-none-win_amd64.whl", hash = "sha256:32fe38614fccc933da77ee4372904a5fa9c12b859209a2e4048a8284c4c371f2"}, + {file = "wasmer_compiler_cranelift-1.1.0-py3-none-any.whl", hash = "sha256:200fea80609cfb088457327acf66d5aa61f4c4f66b5a71133ada960b534c7355"}, +] [[package]] name = "wcwidth" @@ -1478,6 +2275,10 @@ description = "Measures the displayed width of unicode strings in a terminal" category = "dev" optional = false python-versions = "*" +files = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] [[package]] name = "whitenoise" @@ -1486,6 +2287,10 @@ description = "Radically simplified static file serving for WSGI applications" category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "whitenoise-6.2.0-py3-none-any.whl", hash = "sha256:8e9c600a5c18bd17655ef668ad55b5edf6c24ce9bdca5bf607649ca4b1e8e2c2"}, + {file = "whitenoise-6.2.0.tar.gz", hash = "sha256:8fa943c6d4cd9e27673b70c21a07b0aa120873901e099cd46cab40f7cc96d567"}, +] [package.extras] brotli = ["Brotli"] @@ -1497,6 +2302,9 @@ description = "A tool to programmatically control windows inside X" category = "dev" optional = false python-versions = "*" +files = [ + {file = "wmctrl-0.4.tar.gz", hash = "sha256:66cbff72b0ca06a22ec3883ac3a4d7c41078bdae4fb7310f52951769b10e14e0"}, +] [[package]] name = "zipp" @@ -1505,824 +2313,16 @@ description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, + {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, +] [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [metadata] -lock-version = "1.1" +lock-version = "2.0" python-versions = "^3.8" -content-hash = "05a2652964806a1e455e868e9b5f1f9d80d68f76ca3eff4c134b638a2ccd98f0" - -[metadata.files] -appnope = [ - {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, - {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, -] -asgiref = [ - {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, - {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, -] -asttokens = [ - {file = "asttokens-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b28ed85e254b724439afc783d4bee767f780b936c3fe8b3275332f42cf5f561"}, - {file = "asttokens-2.1.0.tar.gz", hash = "sha256:4aa76401a151c8cc572d906aad7aea2a841780834a19d780f4321c0fe1b54635"}, -] -attrs = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, -] -babel = [ - {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, - {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, -] -backcall = [ - {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, - {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, -] -black = [ - {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, - {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, - {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, - {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, - {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, - {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, - {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, - {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, - {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, - {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, - {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, - {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, - {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, - {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, - {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, - {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, - {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, - {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, - {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, -] -certifi = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, -] -cffi = [ - {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, - {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, - {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, - {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, - {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, - {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, - {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, - {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, - {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, - {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, - {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, -] -chardet = [ - {file = "chardet-5.0.0-py3-none-any.whl", hash = "sha256:d3e64f022d254183001eccc5db4040520c0f23b1a3f33d6413e099eb7f126557"}, - {file = "chardet-5.0.0.tar.gz", hash = "sha256:0368df2bfd78b5fc20572bb4e9bb7fb53e2c094f60ae9993339e8671d0afb8aa"}, -] -charset-normalizer = [ - {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, - {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, -] -click = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, -] -colorama = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -coverage = [ - {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, - {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, - {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, - {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, - {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, - {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, - {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, - {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, - {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, - {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, - {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, - {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, - {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, - {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, - {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, - {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, - {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, -] -cryptography = [ - {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:984fe150f350a3c91e84de405fe49e688aa6092b3525f407a18b9646f6612320"}, - {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:ed7b00096790213e09eb11c97cc6e2b757f15f3d2f85833cd2d3ec3fe37c1722"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bbf203f1a814007ce24bd4d51362991d5cb90ba0c177a9c08825f2cc304d871f"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554bec92ee7d1e9d10ded2f7e92a5d70c1f74ba9524947c0ba0c850c7b011828"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b52c9e5f8aa2b802d48bd693190341fae201ea51c7a167d69fc48b60e8a959"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:728f2694fa743a996d7784a6194da430f197d5c58e2f4e278612b359f455e4a2"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfb4f4dd568de1b6af9f4cda334adf7d72cf5bc052516e1b2608b683375dd95c"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5419a127426084933076132d317911e3c6eb77568a1ce23c3ac1e12d111e61e0"}, - {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9b24bcff7853ed18a63cfb0c2b008936a9554af24af2fb146e16d8e1aed75748"}, - {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:25c1d1f19729fb09d42e06b4bf9895212292cb27bb50229f5aa64d039ab29146"}, - {file = "cryptography-38.0.3-cp36-abi3-win32.whl", hash = "sha256:7f836217000342d448e1c9a342e9163149e45d5b5eca76a30e84503a5a96cab0"}, - {file = "cryptography-38.0.3-cp36-abi3-win_amd64.whl", hash = "sha256:c46837ea467ed1efea562bbeb543994c2d1f6e800785bd5a2c98bc096f5cb220"}, - {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06fc3cc7b6f6cca87bd56ec80a580c88f1da5306f505876a71c8cfa7050257dd"}, - {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:65535bc550b70bd6271984d9863a37741352b4aad6fb1b3344a54e6950249b55"}, - {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5e89468fbd2fcd733b5899333bc54d0d06c80e04cd23d8c6f3e0542358c6060b"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6ab9516b85bebe7aa83f309bacc5f44a61eeb90d0b4ec125d2d003ce41932d36"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068147f32fa662c81aebab95c74679b401b12b57494872886eb5c1139250ec5d"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:402852a0aea73833d982cabb6d0c3bb582c15483d29fb7085ef2c42bfa7e38d7"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b1b35d9d3a65542ed2e9d90115dfd16bbc027b3f07ee3304fc83580f26e43249"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6addc3b6d593cd980989261dc1cce38263c76954d758c3c94de51f1e010c9a50"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:be243c7e2bfcf6cc4cb350c0d5cdf15ca6383bbcb2a8ef51d3c9411a9d4386f0"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cf5eefac2b52c10398a42765bfa981ce2372cbc0457e6bf9658f41ec3c41d8"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4e269dcd9b102c5a3d72be3c45d8ce20377b8076a43cbed6f660a1afe365e436"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8d41a46251bf0634e21fac50ffd643216ccecfaf3701a063257fe0b2be1b6548"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:785e4056b5a8b28f05a533fab69febf5004458e20dad7e2e13a3120d8ecec75a"}, - {file = "cryptography-38.0.3.tar.gz", hash = "sha256:bfbe6ee19615b07a98b1d2287d6a6073f734735b49ee45b11324d85efc4d5cbd"}, -] -decorator = [ - {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, - {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, -] -django = [ - {file = "Django-3.2.16-py3-none-any.whl", hash = "sha256:18ba8efa36b69cfcd4b670d0fa187c6fe7506596f0ababe580e16909bcdec121"}, - {file = "Django-3.2.16.tar.gz", hash = "sha256:3adc285124244724a394fa9b9839cc8cd116faf7d159554c43ecdaa8cdf0b94d"}, -] -django-cors-headers = [ - {file = "django-cors-headers-3.13.0.tar.gz", hash = "sha256:f9dc6b4e3f611c3199700b3e5f3398c28757dcd559c2f82932687f3d0443cfdf"}, - {file = "django_cors_headers-3.13.0-py3-none-any.whl", hash = "sha256:37e42883b5f1f2295df6b4bba96eb2417a14a03270cb24b2a07f021cd4487cf4"}, -] -django-environ = [ - {file = "django-environ-0.8.1.tar.gz", hash = "sha256:6f0bc902b43891656b20486938cba0861dc62892784a44919170719572a534cb"}, - {file = "django_environ-0.8.1-py2.py3-none-any.whl", hash = "sha256:42593bee519a527602a467c7b682aee1a051c2597f98c45f4f4f44169ecdb6e5"}, -] -django-excel = [ - {file = "django-excel-0.0.10.tar.gz", hash = "sha256:81cd3bce8007009c30205f7085a97f2908557014900775577cab0b9a770c2bad"}, - {file = "django_excel-0.0.10-py2.py3-none-any.whl", hash = "sha256:f0297202fc460eb74657f8a9d4473921050fbe2e297765c174be9cf33d1195c7"}, -] -django-extensions = [ - {file = "django-extensions-3.2.1.tar.gz", hash = "sha256:2a4f4d757be2563cd1ff7cfdf2e57468f5f931cc88b23cf82ca75717aae504a4"}, - {file = "django_extensions-3.2.1-py3-none-any.whl", hash = "sha256:421464be390289513f86cb5e18eb43e5dc1de8b4c27ba9faa3b91261b0d67e09"}, -] -django-filter = [ - {file = "django-filter-21.1.tar.gz", hash = "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e"}, - {file = "django_filter-21.1-py3-none-any.whl", hash = "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"}, -] -django-hurricane = [ - {file = "django-hurricane-1.3.4.tar.gz", hash = "sha256:16fd74239adc8bba75b988859a3a28820e93f8c1232c65f251e70b790de04e92"}, - {file = "django_hurricane-1.3.4-py3-none-any.whl", hash = "sha256:6706dc95b05d07e4eb32b08b6b7f11ea0fbd3952bb915f0e3648e1df3fa599a2"}, -] -django-money = [ - {file = "django-money-2.1.1.tar.gz", hash = "sha256:4d06041fac5c565ad049a7f8fb4bc33de5c68047b0693efa18a9931cebffb606"}, - {file = "django_money-2.1.1-py3-none-any.whl", hash = "sha256:60a605d5b999e1756a18008dd7e0c5a860fd64c018a140c7a7675a4209ec3782"}, -] -django-multiselectfield = [ - {file = "django-multiselectfield-0.1.12.tar.gz", hash = "sha256:d0a4c71568fb2332c71478ffac9f8708e01314a35cf923dfd7a191343452f9f9"}, - {file = "django_multiselectfield-0.1.12-py3-none-any.whl", hash = "sha256:c270faa7f80588214c55f2d68cbddb2add525c2aa830c216b8a198de914eb470"}, -] -django-nested-inline = [ - {file = "django-nested-inline-0.4.5.tar.gz", hash = "sha256:d7e51dc1ebf805df53ec7ff9b4108dfe1dfb8a7e02212700a4e467f2caafe34b"}, - {file = "django_nested_inline-0.4.5-py3-none-any.whl", hash = "sha256:fc6998e7d607c1414e25897ae1a544105ada93d63c5cff3ac9582fbf14d8ec63"}, -] -django-prometheus = [ - {file = "django-prometheus-2.2.0.tar.gz", hash = "sha256:240378a1307c408bd5fc85614a3a57f1ce633d4a222c9e291e2bbf325173b801"}, - {file = "django_prometheus-2.2.0-py2.py3-none-any.whl", hash = "sha256:e6616770d8820b8834762764bf1b76ec08e1b98e72a6f359d488a2e15fe3537c"}, -] -djangorestframework = [ - {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, - {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, -] -djangorestframework-jsonapi = [ - {file = "djangorestframework-jsonapi-5.0.0.tar.gz", hash = "sha256:090c568dc99380ead71cc378020b4cd191db2ffce9ab3e9339df80d5d82c8648"}, - {file = "djangorestframework_jsonapi-5.0.0-py2.py3-none-any.whl", hash = "sha256:f25b0d24a990690e578668b7a7a191a75162f1d9561abd773d12de331cf16673"}, -] -et-xmlfile = [ - {file = "et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"}, - {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, -] -exceptiongroup = [ - {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, - {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, -] -executing = [ - {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"}, - {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"}, -] -factory-boy = [ - {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, - {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, -] -faker = [ - {file = "Faker-15.3.1-py3-none-any.whl", hash = "sha256:4a3465624515a6807e8aa7e8eeb85bdd86a2fa53de4e258892dd6be95362462e"}, - {file = "Faker-15.3.1.tar.gz", hash = "sha256:b9dd2fd9a9ac68a4e0c5040cd9e9bfaa099fa8dd15bae5f01f224a45431818d5"}, -] -fancycompleter = [ - {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, - {file = "fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272"}, -] -fastdiff = [ - {file = "fastdiff-0.3.0-py2.py3-none-any.whl", hash = "sha256:ca5f61f6ddf5a1564ddfd98132ad28e7abe4a88a638a8b014a2214f71e5918ec"}, - {file = "fastdiff-0.3.0.tar.gz", hash = "sha256:4dfa09c47832a8c040acda3f1f55fc0ab4d666f0e14e6951e6da78d59acd945a"}, -] -flake8 = [ - {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, - {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, -] -flake8-blind-except = [ - {file = "flake8-blind-except-0.2.1.tar.gz", hash = "sha256:f25a575a9dcb3eeb3c760bf9c22db60b8b5a23120224ed1faa9a43f75dd7dd16"}, -] -flake8-debugger = [ - {file = "flake8-debugger-4.1.2.tar.gz", hash = "sha256:52b002560941e36d9bf806fca2523dc7fb8560a295d5f1a6e15ac2ded7a73840"}, - {file = "flake8_debugger-4.1.2-py3-none-any.whl", hash = "sha256:0a5e55aeddcc81da631ad9c8c366e7318998f83ff00985a49e6b3ecf61e571bf"}, -] -flake8-deprecated = [ - {file = "flake8-deprecated-1.3.tar.gz", hash = "sha256:9fa5a0c5c81fb3b34c53a0e4f16cd3f0a3395078cfd4988011cbab5fb0afa7f7"}, - {file = "flake8_deprecated-1.3-py2.py3-none-any.whl", hash = "sha256:211951854837ced9ec997a75c6e5b957f3536a735538ee0620b76539fd3706cd"}, -] -flake8-docstrings = [ - {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, - {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, -] -flake8-isort = [ - {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, - {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, -] -flake8-string-format = [ - {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, - {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, -] -freezegun = [ - {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, - {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, -] -idna = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, -] -importlib-metadata = [ - {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, - {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, -] -inflection = [ - {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, - {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, -] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] -ipdb = [ - {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, -] -ipython = [ - {file = "ipython-8.6.0-py3-none-any.whl", hash = "sha256:91ef03016bcf72dd17190f863476e7c799c6126ec7e8be97719d1bc9a78a59a4"}, - {file = "ipython-8.6.0.tar.gz", hash = "sha256:7c959e3dedbf7ed81f9b9d8833df252c430610e2a4a6464ec13cd20975ce20a5"}, -] -isort = [ - {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, - {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, -] -jedi = [ - {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, - {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, -] -josepy = [ - {file = "josepy-1.13.0-py2.py3-none-any.whl", hash = "sha256:6f64eb35186aaa1776b7a1768651b1c616cab7f9685f9660bffc6491074a5390"}, - {file = "josepy-1.13.0.tar.gz", hash = "sha256:8931daf38f8a4c85274a0e8b7cb25addfd8d1f28f9fb8fbed053dd51aec75dc9"}, -] -lml = [ - {file = "lml-0.1.0-py2.py3-none-any.whl", hash = "sha256:ec06e850019942a485639c8c2a26bdb99eae24505bee7492b649df98a0bed101"}, - {file = "lml-0.1.0.tar.gz", hash = "sha256:57a085a29bb7991d70d41c6c3144c560a8e35b4c1030ffb36d85fa058773bcc5"}, -] -lxml = [ - {file = "lxml-4.9.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed"}, - {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc"}, - {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc"}, - {file = "lxml-4.9.1-cp27-cp27m-win32.whl", hash = "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3"}, - {file = "lxml-4.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627"}, - {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84"}, - {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8"}, - {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8"}, - {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d"}, - {file = "lxml-4.9.1-cp310-cp310-win32.whl", hash = "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7"}, - {file = "lxml-4.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b"}, - {file = "lxml-4.9.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d"}, - {file = "lxml-4.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3"}, - {file = "lxml-4.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29"}, - {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d"}, - {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318"}, - {file = "lxml-4.9.1-cp35-cp35m-win32.whl", hash = "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7"}, - {file = "lxml-4.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4"}, - {file = "lxml-4.9.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf"}, - {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3"}, - {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391"}, - {file = "lxml-4.9.1-cp36-cp36m-win32.whl", hash = "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e"}, - {file = "lxml-4.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7"}, - {file = "lxml-4.9.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca"}, - {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785"}, - {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785"}, - {file = "lxml-4.9.1-cp37-cp37m-win32.whl", hash = "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a"}, - {file = "lxml-4.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e"}, - {file = "lxml-4.9.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715"}, - {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036"}, - {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387"}, - {file = "lxml-4.9.1-cp38-cp38-win32.whl", hash = "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94"}, - {file = "lxml-4.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345"}, - {file = "lxml-4.9.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000"}, - {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25"}, - {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd"}, - {file = "lxml-4.9.1-cp39-cp39-win32.whl", hash = "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb"}, - {file = "lxml-4.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73"}, - {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c"}, - {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"}, - {file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"}, -] -matplotlib-inline = [ - {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, - {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, -] -mccabe = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] -mozilla-django-oidc = [ - {file = "mozilla-django-oidc-2.0.0.tar.gz", hash = "sha256:a8b2f27c69c122d2f4d801c3759761d33debf06ae9dabbab8aed82887bba3bb8"}, - {file = "mozilla_django_oidc-2.0.0-py2.py3-none-any.whl", hash = "sha256:53c39755b667e8c5923b1dffc3c29673198d03aa107aa42ac86b8a38b4720c25"}, -] -mypy-extensions = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, -] -openpyxl = [ - {file = "openpyxl-3.0.10-py2.py3-none-any.whl", hash = "sha256:0ab6d25d01799f97a9464630abacbb34aafecdcaa0ef3cba6d6b3499867d0355"}, - {file = "openpyxl-3.0.10.tar.gz", hash = "sha256:e47805627aebcf860edb4edf7987b1309c1b3632f3750538ed962bbcc3bd7449"}, -] -packaging = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, -] -parso = [ - {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, - {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, -] -pathspec = [ - {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, - {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, -] -pdbpp = [ - {file = "pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1"}, - {file = "pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5"}, -] -pexpect = [ - {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, - {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, -] -pickleshare = [ - {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, - {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, -] -pika = [ - {file = "pika-1.1.0-py2.py3-none-any.whl", hash = "sha256:4e1a1a6585a41b2341992ec32aadb7a919d649eb82904fd8e4a4e0871c8cf3af"}, - {file = "pika-1.1.0.tar.gz", hash = "sha256:9fa76ba4b65034b878b2b8de90ff8660a59d925b087c5bb88f8fdbb4b64a1dbf"}, -] -platformdirs = [ - {file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, - {file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -prometheus-client = [ - {file = "prometheus_client-0.15.0-py3-none-any.whl", hash = "sha256:db7c05cbd13a0f79975592d112320f2605a325969b270a94b71dcabc47b931d2"}, - {file = "prometheus_client-0.15.0.tar.gz", hash = "sha256:be26aa452490cfcf6da953f9436e95a9f2b4d578ca80094b4458930e5f584ab1"}, -] -prompt-toolkit = [ - {file = "prompt_toolkit-3.0.32-py3-none-any.whl", hash = "sha256:24becda58d49ceac4dc26232eb179ef2b21f133fecda7eed6018d341766ed76e"}, - {file = "prompt_toolkit-3.0.32.tar.gz", hash = "sha256:e7f2129cba4ff3b3656bbdda0e74ee00d2f874a8bcdb9dd16f5fec7b3e173cae"}, -] -psycopg2-binary = [ - {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:0775d6252ccb22b15da3b5d7adbbf8cfe284916b14b6dc0ff503a23edb01ee85"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3520d7af1ebc838cc6084a3281145d5cd5bdd43fdef139e6db5af01b92596cb7"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cbc554ba47ecca8cd3396ddaca85e1ecfe3e48dd57dc5e415e59551affe568e"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:5d28ecdf191db558d0c07d0f16524ee9d67896edf2b7990eea800abeb23ebd61"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05b3d479425e047c848b9782cd7aac9c6727ce23181eb9647baf64ffdfc3da41"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e491e6489a6cb1d079df8eaa15957c277fdedb102b6a68cfbf40c4994412fd0"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:9e32cedc389bcb76d9f24ea8a012b3cb8385ee362ea437e1d012ffaed106c17d"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_10_9_universal2.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:426c2ae999135d64e6a18849a7d1ad0e1bd007277e4a8f4752eaa40a96b550ff"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:af0516e1711995cb08dc19bbd05bec7dbdebf4185f68870595156718d237df3e"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e72c91bda9880f097c8aa3601a2c0de6c708763ba8128006151f496ca9065935"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:56b2957a145f816726b109ee3d4e6822c23f919a7d91af5a94593723ed667835"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:70831e03bd53702c941da1a1ad36c17d825a24fbb26857b40913d58df82ec18b"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dbc332beaf8492b5731229a881807cd7b91b50dbbbaf7fe2faf46942eda64a24"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:95076399ec3b27a8f7fa1cc9a83417b1c920d55cf7a97f718a94efbb96c7f503"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-win32.whl", hash = "sha256:3fc33295cfccad697a97a76dec3f1e94ad848b7b163c3228c1636977966b51e2"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-win_amd64.whl", hash = "sha256:02551647542f2bf89073d129c73c05a25c372fc0a49aa50e0de65c3c143d8bd0"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:63e318dbe52709ed10d516a356f22a635e07a2e34c68145484ed96a19b0c4c68"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e518a0911c50f60313cb9e74a169a65b5d293770db4770ebf004245f24b5c5"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:68d81a2fe184030aa0c5c11e518292e15d342a667184d91e30644c9d533e53e1"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:7ee3095d02d6f38bd7d9a5358fcc9ea78fcdb7176921528dd709cc63f40184f5"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:46512486be6fbceef51d7660dec017394ba3e170299d1dc30928cbedebbf103a"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b911dfb727e247340d36ae20c4b9259e4a64013ab9888ccb3cbba69b77fd9636"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:422e3d43b47ac20141bc84b3d342eead8d8099a62881a501e97d15f6addabfe9"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-win32.whl", hash = "sha256:b8104f709590fff72af801e916817560dbe1698028cd0afe5a52d75ceb1fce5f"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-win_amd64.whl", hash = "sha256:7b3751857da3e224f5629400736a7b11e940b5da5f95fa631d86219a1beaafec"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:043a9fd45a03858ff72364b4b75090679bd875ee44df9c0613dc862ca6b98460"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9ffdc51001136b699f9563b1c74cc1f8c07f66ef7219beb6417a4c8aaa896c28"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc85b3777068ed30aff8242be2813038a929f2084f69e43ef869daddae50f6ee"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:7d07f552d1e412f4b4e64ce386d4c777a41da3b33f7098b6219012ba534fb2c2"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a0adef094c49f242122bb145c3c8af442070dc0e4312db17e49058c1702606d4"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00475004e5ed3e3bf5e056d66e5dcdf41a0dc62efcd57997acd9135c40a08a50"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7d88db096fa19d94f433420eaaf9f3c45382da2dd014b93e4bf3215639047c16"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:902844f9c4fb19b17dfa84d9e2ca053d4a4ba265723d62ea5c9c26b38e0aa1e6"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-win32.whl", hash = "sha256:4e7904d1920c0c89105c0517dc7e3f5c20fb4e56ba9cdef13048db76947f1d79"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:a36a0e791805aa136e9cbd0ffa040d09adec8610453ee8a753f23481a0057af5"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9c38d3869238e9d3409239bc05bc27d6b7c99c2a460ea337d2814b35fb4fea1b"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6527c8efa5226a9e787507652dd5ba97b62d29b53c371a85cd13f957fe4d42"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59137cdb970249ae60be2a49774c6dfb015bd0403f05af1fe61862e9626642d"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:d4c7b3a31502184e856df1f7bbb2c3735a05a8ce0ade34c5277e1577738a5c91"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b9a794cef1d9c1772b94a72eec6da144c18e18041d294a9ab47669bc77a80c1d"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5e65c6ac0ae4bf5bef1667029f81010b6017795dcb817ba5c7b8a8d61fab76f"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:74eddec4537ab1f701a1647214734bc52cee2794df748f6ae5908e00771f180a"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:01ad49d68dd8c5362e4bfb4158f2896dc6e0c02e87b8a3770fc003459f1a4425"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-win32.whl", hash = "sha256:937880290775033a743f4836aa253087b85e62784b63fd099ee725d567a48aa1"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:484405b883630f3e74ed32041a87456c5e0e63a8e3429aa93e8714c366d62bd1"}, -] -ptyprocess = [ - {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, - {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, -] -pure-eval = [ - {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, - {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, -] -py-moneyed = [ - {file = "py-moneyed-1.2.tar.gz", hash = "sha256:d745a52819604f42b3666f9b2504b71c27c1645d6d5027d95ec5ed1f28ca86e3"}, - {file = "py_moneyed-1.2-py2.py3-none-any.whl", hash = "sha256:c6131c7b7c1f8503552afe44d15c343ea50282d1d9e6fa8b3f1bd2affc1dae1e"}, -] -pycodestyle = [ - {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, - {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, -] -pycparser = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, -] -pydocstyle = [ - {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, - {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, -] -pyexcel = [ - {file = "pyexcel-0.7.0-py2.py3-none-any.whl", hash = "sha256:ddc6904512bfa2ecda509fb3b58229bb30db14498632fd9e7a5ba7bbfb02ed1b"}, - {file = "pyexcel-0.7.0.tar.gz", hash = "sha256:fbf0eee5d93b96cef6f19a9f00703f22c0a64f19728d91b95428009a52129709"}, -] -pyexcel-ezodf = [ - {file = "pyexcel-ezodf-0.3.4.tar.gz", hash = "sha256:972eeea9b0e4bab60dfc5cdcb7378cc7ba5e070a0b7282746c0182c5de011ff1"}, - {file = "pyexcel_ezodf-0.3.4-py2.py3-none-any.whl", hash = "sha256:a74ac7636a015fff31d35c5350dc5ad347ba98ecb453de4dbcbb9a9168434e8c"}, -] -pyexcel-io = [ - {file = "pyexcel-io-0.6.6.tar.gz", hash = "sha256:f6084bf1afa5fbf4c61cf7df44370fa513821af188b02e3e19b5efb66d8a969f"}, - {file = "pyexcel_io-0.6.6-py2.py3-none-any.whl", hash = "sha256:19ff1d599a8a6c0982e4181ef86aa50e1f8d231410fa7e0e204d62e37551c1d6"}, -] -pyexcel-ods3 = [ - {file = "pyexcel-ods3-0.6.1.tar.gz", hash = "sha256:53740fc9bc6e91e43cdc0ee4f557bb3b252d8493d34f2c11d26a93c53cfebc2e"}, - {file = "pyexcel_ods3-0.6.1-py3-none-any.whl", hash = "sha256:ca61d139879349a5d4b0a241add6504474c59fa280d1804b76f56ee4ba30eb8b"}, -] -pyexcel-webio = [ - {file = "pyexcel-webio-0.1.4.tar.gz", hash = "sha256:039538f1b35351f1632891dde29ef4d7fba744e217678ebb5a501336e28ca265"}, - {file = "pyexcel_webio-0.1.4-py2.py3-none-any.whl", hash = "sha256:3583cf7dcddb747520a8a90e93cf07b0584878b56b3c41c46d132b458a6cfd00"}, -] -pyexcel-xlsx = [ - {file = "pyexcel-xlsx-0.6.0.tar.gz", hash = "sha256:55754f764252461aca6871db203f4bd1370ec877828e305e6be1de5f9aa6a79d"}, - {file = "pyexcel_xlsx-0.6.0-py2.py3-none-any.whl", hash = "sha256:16530f96a77c97ebcba7941517d2756ac52d3ce2903d81eecd7f300778d5242a"}, -] -pyflakes = [ - {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, - {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, -] -pygments = [ - {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, - {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, -] -pyopenssl = [ - {file = "pyOpenSSL-22.1.0-py3-none-any.whl", hash = "sha256:b28437c9773bb6c6958628cf9c3bebe585de661dba6f63df17111966363dd15e"}, - {file = "pyOpenSSL-22.1.0.tar.gz", hash = "sha256:7a83b7b272dd595222d672f5ce29aa030f1fb837630ef229f62e72e395ce8968"}, -] -pyparsing = [ - {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, - {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, -] -pyreadline = [ - {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, -] -pyrepl = [ - {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, -] -pytest = [ - {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, - {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, -] -pytest-cov = [ - {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, - {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, -] -pytest-django = [ - {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, - {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, -] -pytest-env = [ - {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"}, -] -pytest-factoryboy = [ - {file = "pytest-factoryboy-2.1.0.tar.gz", hash = "sha256:23bc562ab32cc39eddfbbbf70e618a1b30e834a4cfa451c4bedc36216f0a7b19"}, - {file = "pytest_factoryboy-2.1.0-py3-none-any.whl", hash = "sha256:10c02d2736cb52c7af28065db9617e7f50634e95eaa07eeb9a007026aa3dc0a8"}, -] -pytest-freezegun = [ - {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"}, - {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"}, -] -pytest-mock = [ - {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, - {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, -] -pytest-randomly = [ - {file = "pytest-randomly-3.12.0.tar.gz", hash = "sha256:d60c2db71ac319aee0fc6c4110a7597d611a8b94a5590918bfa8583f00caccb2"}, - {file = "pytest_randomly-3.12.0-py3-none-any.whl", hash = "sha256:f4f2e803daf5d1ba036cc22bf4fe9dbbf99389ec56b00e5cba732fb5c1d07fdd"}, -] -python-dateutil = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] -python-redmine = [ - {file = "python-redmine-2.3.0.tar.gz", hash = "sha256:96889d1ae59b5830337e2c2fff1e2ce54103e52bbb632bd7c648f7d2d0274d25"}, - {file = "python_redmine-2.3.0-py2.py3-none-any.whl", hash = "sha256:502680473a3f9b7a001f788969c62081023afa83d896c0a54963aed3cf198b89"}, -] -pytz = [ - {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, - {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, -] -requests = [ - {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, - {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, -] -requests-mock = [ - {file = "requests-mock-1.9.3.tar.gz", hash = "sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba"}, - {file = "requests_mock-1.9.3-py2.py3-none-any.whl", hash = "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970"}, -] -sentry-sdk = [ - {file = "sentry-sdk-1.14.0.tar.gz", hash = "sha256:273fe05adf052b40fd19f6d4b9a5556316807246bd817e5e3482930730726bb0"}, - {file = "sentry_sdk-1.14.0-py2.py3-none-any.whl", hash = "sha256:72c00322217d813cf493fe76590b23a757e063ff62fec59299f4af7201dd4448"}, -] -setuptools = [ - {file = "setuptools-65.5.1-py3-none-any.whl", hash = "sha256:d0b9a8433464d5800cbe05094acf5c6d52a91bfac9b52bcfc4d41382be5d5d31"}, - {file = "setuptools-65.5.1.tar.gz", hash = "sha256:e197a19aa8ec9722928f2206f8de752def0e4c9fc6953527360d1c36d94ddb2f"}, -] -six = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] -snapshottest = [ - {file = "snapshottest-0.6.0-py2.py3-none-any.whl", hash = "sha256:9b177cffe0870c589df8ddbee0a770149c5474b251955bdbde58b7f32a4ec429"}, - {file = "snapshottest-0.6.0.tar.gz", hash = "sha256:bbcaf81d92d8e330042e5c928e13d9f035e99e91b314fe55fda949c2f17b653c"}, -] -snowballstemmer = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] -sqlparse = [ - {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, - {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, -] -stack-data = [ - {file = "stack_data-0.6.0-py3-none-any.whl", hash = "sha256:b92d206ef355a367d14316b786ab41cb99eb453a21f2cb216a4204625ff7bc07"}, - {file = "stack_data-0.6.0.tar.gz", hash = "sha256:8e515439f818efaa251036af72d89e4026e2b03993f3453c000b200fb4f2d6aa"}, -] -termcolor = [ - {file = "termcolor-2.1.0-py3-none-any.whl", hash = "sha256:91dd04fdf661b89d7169cefd35f609b19ca931eb033687eaa647cef1ff177c49"}, - {file = "termcolor-2.1.0.tar.gz", hash = "sha256:b80df54667ce4f48c03fe35df194f052dc27a541ebbf2544e4d6b47b5d6949c4"}, -] -testfixtures = [ - {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"}, - {file = "testfixtures-6.18.5.tar.gz", hash = "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84"}, -] -texttable = [ - {file = "texttable-1.6.4-py2.py3-none-any.whl", hash = "sha256:dd2b0eaebb2a9e167d1cefedab4700e5dcbdb076114eed30b58b97ed6b37d6f2"}, - {file = "texttable-1.6.4.tar.gz", hash = "sha256:42ee7b9e15f7b225747c3fa08f43c5d6c83bc899f80ff9bae9319334824076e9"}, -] -toml = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] -tornado = [ - {file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"}, - {file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"}, - {file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"}, - {file = "tornado-6.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"}, - {file = "tornado-6.2-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a2f5999215a3a06a4fc218026cd84c61b8b2b40ac5296a6db1f1451ef04c1e"}, - {file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"}, - {file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"}, - {file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"}, - {file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"}, - {file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"}, - {file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"}, -] -traitlets = [ - {file = "traitlets-5.5.0-py3-none-any.whl", hash = "sha256:1201b2c9f76097195989cdf7f65db9897593b0dfd69e4ac96016661bb6f0d30f"}, - {file = "traitlets-5.5.0.tar.gz", hash = "sha256:b122f9ff2f2f6c1709dab289a05555be011c87828e911c0cf4074b85cb780a79"}, -] -typing-extensions = [ - {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, - {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, -] -urllib3 = [ - {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, - {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, -] -wasmer = [ - {file = "wasmer-1.1.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:c2af4b907ae2dabcac41e316e811d5937c93adf1f8b05c5d49427f8ce0f37630"}, - {file = "wasmer-1.1.0-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:ab1ae980021e5ec0bf0c6cdd3b979b1d15a5f3eb2b8a32da8dcb1156e4a1e484"}, - {file = "wasmer-1.1.0-cp310-none-win_amd64.whl", hash = "sha256:d0d93aec6215893d33e803ef0a8d37bf948c585dd80ba0e23a83fafee820bc03"}, - {file = "wasmer-1.1.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:1e63d16bd6e2e2272d8721647831de5c537e0bb08002ee6d7abf167ec02d5178"}, - {file = "wasmer-1.1.0-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:85e6a5bf44853e8e6a12e947ee3412da9e84f7ce49fc165ba5dbd293e9c5c405"}, - {file = "wasmer-1.1.0-cp37-none-win_amd64.whl", hash = "sha256:a182a6eca9b46d895b4985fc822fab8da3d2f84fab74ca27e55a7430a7fcf336"}, - {file = "wasmer-1.1.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:214d9a3cfb577ea9449eb2b5f13adceae34c55365e4c3d930066beb86a7f67bc"}, - {file = "wasmer-1.1.0-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:b9e5605552bd7d2bc6337519b176defe83bc69b98abf3caaaefa4f7ec231d18a"}, - {file = "wasmer-1.1.0-cp38-none-win_amd64.whl", hash = "sha256:20b5190112e2e94a8947967f2bc683c9685855d0f34130d8434c87a55216a3bd"}, - {file = "wasmer-1.1.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:ee442f0970f40ec5e32011c92fd753fb2061da0faa13de13fafc730c31be34e3"}, - {file = "wasmer-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa112198b743cff2e391230436813fb4b244a24443e37866522b7197e3a034da"}, - {file = "wasmer-1.1.0-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:c0b37117f6d3ff51ee96431c7d224d99799b08d174e30fcd0fcd7e2e3cb8140c"}, - {file = "wasmer-1.1.0-cp39-none-win_amd64.whl", hash = "sha256:a0a4730ec4907a4cb0d9d4a77ea2608c2c814f22a22b73fc80be0f110e014836"}, - {file = "wasmer-1.1.0-py3-none-any.whl", hash = "sha256:2caf8c67feae9cd4246421551036917811c446da4f27ad4c989521ef42751931"}, -] -wasmer-compiler-cranelift = [ - {file = "wasmer_compiler_cranelift-1.1.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:9869910179f39696a020edc5689f7759257ac1cce569a7a0fcf340c59788baad"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:405546ee864ac158a4107f374dfbb1c8d6cfb189829bdcd13050143a4bd98f28"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp310-none-win_amd64.whl", hash = "sha256:bdf75af9ef082e6aeb752550f694273340ece970b65099e0746db0f972760d11"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:7d9c782b7721789b16e303b7e70c59df370896dd62b77e2779e3a44b4e1aa20c"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:ff7dd5bd69030b63521c24583bf0f5457cd2580237340b91ce35370f72a4a1cc"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp37-none-win_amd64.whl", hash = "sha256:447285402e366a34667a674db70458c491acd6940b797c175c0b0027f48e64bb"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:55a524985179f6b7b88ac973e8fac5a2574d3b125a966fba75fedd5a2525e484"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:bd03db5a916ead51b442c66acad38847dfe127cf90b2019b1680f1920c4f8d06"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp38-none-win_amd64.whl", hash = "sha256:157d87cbd1d04adbad55b50cb4bedc28e444caf74797fd96df17390667e58699"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:ff25fc99ebafa04a6c271d08a90d17b927930e3019a2b333c7cfb48ba32c6f71"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9697ae082317a56776df8ff7df8c922eac38488ef38d3478fe5f0ca144c185ab"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:2a4349b1ddd727bd46bc5ede741839dcfc5153c52f064a83998c4150d5d4a85c"}, - {file = "wasmer_compiler_cranelift-1.1.0-cp39-none-win_amd64.whl", hash = "sha256:32fe38614fccc933da77ee4372904a5fa9c12b859209a2e4048a8284c4c371f2"}, - {file = "wasmer_compiler_cranelift-1.1.0-py3-none-any.whl", hash = "sha256:200fea80609cfb088457327acf66d5aa61f4c4f66b5a71133ada960b534c7355"}, -] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] -whitenoise = [ - {file = "whitenoise-6.2.0-py3-none-any.whl", hash = "sha256:8e9c600a5c18bd17655ef668ad55b5edf6c24ce9bdca5bf607649ca4b1e8e2c2"}, - {file = "whitenoise-6.2.0.tar.gz", hash = "sha256:8fa943c6d4cd9e27673b70c21a07b0aa120873901e099cd46cab40f7cc96d567"}, -] -wmctrl = [ - {file = "wmctrl-0.4.tar.gz", hash = "sha256:66cbff72b0ca06a22ec3883ac3a4d7c41078bdae4fb7310f52951769b10e14e0"}, -] -zipp = [ - {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, - {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, -] +content-hash = "b95888749831a6e394548b5e999e940a9ae252d4109387f887d79e711e64ee86" diff --git a/pyproject.toml b/pyproject.toml index 1add20883..507d5aafe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ include = ["CHANGELOG.md"] [tool.poetry.dependencies] python = "^3.8" python-dateutil = "^2.8.2" -django = "^3.2.13" +django = "^3.2.18" # might remove this once we find out how the jsonapi extras_require work django-cors-headers = "^3.13.0" django-filter = "^21.1" From 4750bcfbccc2b9af4710d6789de94e6aab6eb191 Mon Sep 17 00:00:00 2001 From: Falk Neumann Date: Fri, 17 Feb 2023 14:57:39 +0000 Subject: [PATCH 938/980] doc: update and fix commands --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8d31b6876..07375f6c1 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ The username and password are identical. To access the Django admin interface you will have to change the admin password in Django directly: ```console -$ make shell -root@0a036a10f3c4:/app# peotry run python manage.py changepassword admin +$ make bash +root@0a036a10f3c4:/app# poetry run python manage.py changepassword admin Changing password for user 'admin' Password: Password (again): From abceb322e042df5c34b04e685c331527848c898f Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 20 Feb 2023 09:46:43 +0100 Subject: [PATCH 939/980] fix(tracking): fix remaining effort on report creation update most_recent_remaining_effort on task and total_remaining_effort on project when creating a report --- timed/tracking/signals.py | 37 ++++++++++++++++++----------- timed/tracking/tests/test_report.py | 4 ++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/timed/tracking/signals.py b/timed/tracking/signals.py index 102b04d62..c08cede52 100644 --- a/timed/tracking/signals.py +++ b/timed/tracking/signals.py @@ -26,21 +26,30 @@ def update_most_recent_remaining_effort(sender, instance, **kwargs): if kwargs.get("raw", False): # pragma: no cover return - if not instance.pk: - return if instance.task.project.remaining_effort_tracking is not True: return + # update most_recent_remaining_effort and total_remaining_effort on report creation + if not instance.pk: + update_remaining_effort(instance) + return + + # check if remaining effort has changed on report update if instance.remaining_effort != Report.objects.get(id=instance.id).remaining_effort: - task = instance.task - task.most_recent_remaining_effort = instance.remaining_effort - task.save() - - project = task.project - total_remaining_effort = ( - project.tasks.all() - .aggregate(sum_remaining=Sum("most_recent_remaining_effort")) - .get("sum_remaining") - ) - project.total_remaining_effort = total_remaining_effort - project.save() + update_remaining_effort(instance) + + +def update_remaining_effort(report): + task = report.task + project = task.project + + task.most_recent_remaining_effort = report.remaining_effort + task.save() + + total_remaining_effort = ( + task.project.tasks.all() + .aggregate(sum_remaining=Sum("most_recent_remaining_effort")) + .get("sum_remaining") + ) + project.total_remaining_effort = total_remaining_effort + project.save() diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 6343ec0f8..760ccd3b9 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1905,6 +1905,10 @@ def test_report_create_remaining_effort( assert json["data"]["relationships"]["user"]["data"]["id"] == str(user.id) assert json["data"]["relationships"]["task"]["data"]["id"] == str(task.id) + task.refresh_from_db() + assert task.most_recent_remaining_effort == timedelta(hours=1) + assert task.project.total_remaining_effort == timedelta(hours=1) + def test_report_remaining_effort_total( internal_employee_client, From 4688e41da5300d789ed50bdd6af34d7d481767c8 Mon Sep 17 00:00:00 2001 From: Falk Date: Fri, 17 Feb 2023 13:42:51 +0100 Subject: [PATCH 940/980] feat(filters): add number multi value filter --- timed/projects/filters.py | 2 ++ timed/projects/tests/test_project.py | 14 ++++++++++++++ timed/projects/tests/test_task.py | 14 ++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 420f6d527..a26c29713 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -30,6 +30,7 @@ class ProjectFilterSet(FilterSet): archived = NumberFilter(field_name="archived") has_manager = NumberFilter(method="filter_has_manager") has_reviewer = NumberFilter(method="filter_has_reviewer") + customer = NumberInFilter(field_name="customer") def filter_has_manager(self, queryset, name, value): if not value: # pragma: no cover @@ -115,6 +116,7 @@ class TaskFilterSet(FilterSet): my_most_frequent = MyMostFrequentTaskFilter() archived = NumberFilter(field_name="archived") + project = NumberInFilter(field_name="project") class Meta: """Meta information for the task filter set.""" diff --git a/timed/projects/tests/test_project.py b/timed/projects/tests/test_project.py index 8f48d91c2..febb48d45 100644 --- a/timed/projects/tests/test_project.py +++ b/timed/projects/tests/test_project.py @@ -154,6 +154,20 @@ def test_project_filter(internal_employee_client): assert len(json["data"]) == 1 +def test_project_multi_number_value_filter(internal_employee_client): + proj1, proj2, *_ = ProjectFactory.create_batch(4) + + url = reverse("project-list") + + response = internal_employee_client.get( + url, {"customer": (",").join([str(proj1.customer.id), str(proj2.customer.id)])} + ) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 2 + + def test_project_update_billed_flag(internal_employee_client, report_factory): report = report_factory.create() project = report.task.project diff --git a/timed/projects/tests/test_task.py b/timed/projects/tests/test_task.py index 6d1b79f0c..8fa560496 100644 --- a/timed/projects/tests/test_task.py +++ b/timed/projects/tests/test_task.py @@ -229,3 +229,17 @@ def test_task_list_no_employment(auth_client, is_customer, customer_visible, exp json = response.json() assert len(json["data"]) == expected + + +def test_task_multi_number_value_filter(internal_employee_client): + task1, task2, *_ = TaskFactory.create_batch(4) + + url = reverse("task-list") + + response = internal_employee_client.get( + url, {"project": (",").join([str(task1.project.id), str(task2.project.id)])} + ) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + assert len(json["data"]) == 2 From fa7859244663436fc0d740ad7c2f814f63e1ea4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Feb 2023 08:05:23 +0000 Subject: [PATCH 941/980] chore(deps): bump cryptography from 38.0.3 to 39.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 38.0.3 to 39.0.1. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/38.0.3...39.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 67 ++++++++++++++++++++++++++--------------------------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/poetry.lock b/poetry.lock index 40aa2378e..5799d31f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -339,50 +339,49 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "38.0.3" +version = "39.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:984fe150f350a3c91e84de405fe49e688aa6092b3525f407a18b9646f6612320"}, - {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:ed7b00096790213e09eb11c97cc6e2b757f15f3d2f85833cd2d3ec3fe37c1722"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bbf203f1a814007ce24bd4d51362991d5cb90ba0c177a9c08825f2cc304d871f"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554bec92ee7d1e9d10ded2f7e92a5d70c1f74ba9524947c0ba0c850c7b011828"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b52c9e5f8aa2b802d48bd693190341fae201ea51c7a167d69fc48b60e8a959"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:728f2694fa743a996d7784a6194da430f197d5c58e2f4e278612b359f455e4a2"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfb4f4dd568de1b6af9f4cda334adf7d72cf5bc052516e1b2608b683375dd95c"}, - {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5419a127426084933076132d317911e3c6eb77568a1ce23c3ac1e12d111e61e0"}, - {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9b24bcff7853ed18a63cfb0c2b008936a9554af24af2fb146e16d8e1aed75748"}, - {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:25c1d1f19729fb09d42e06b4bf9895212292cb27bb50229f5aa64d039ab29146"}, - {file = "cryptography-38.0.3-cp36-abi3-win32.whl", hash = "sha256:7f836217000342d448e1c9a342e9163149e45d5b5eca76a30e84503a5a96cab0"}, - {file = "cryptography-38.0.3-cp36-abi3-win_amd64.whl", hash = "sha256:c46837ea467ed1efea562bbeb543994c2d1f6e800785bd5a2c98bc096f5cb220"}, - {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06fc3cc7b6f6cca87bd56ec80a580c88f1da5306f505876a71c8cfa7050257dd"}, - {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:65535bc550b70bd6271984d9863a37741352b4aad6fb1b3344a54e6950249b55"}, - {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5e89468fbd2fcd733b5899333bc54d0d06c80e04cd23d8c6f3e0542358c6060b"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6ab9516b85bebe7aa83f309bacc5f44a61eeb90d0b4ec125d2d003ce41932d36"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068147f32fa662c81aebab95c74679b401b12b57494872886eb5c1139250ec5d"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:402852a0aea73833d982cabb6d0c3bb582c15483d29fb7085ef2c42bfa7e38d7"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b1b35d9d3a65542ed2e9d90115dfd16bbc027b3f07ee3304fc83580f26e43249"}, - {file = "cryptography-38.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6addc3b6d593cd980989261dc1cce38263c76954d758c3c94de51f1e010c9a50"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:be243c7e2bfcf6cc4cb350c0d5cdf15ca6383bbcb2a8ef51d3c9411a9d4386f0"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cf5eefac2b52c10398a42765bfa981ce2372cbc0457e6bf9658f41ec3c41d8"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4e269dcd9b102c5a3d72be3c45d8ce20377b8076a43cbed6f660a1afe365e436"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8d41a46251bf0634e21fac50ffd643216ccecfaf3701a063257fe0b2be1b6548"}, - {file = "cryptography-38.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:785e4056b5a8b28f05a533fab69febf5004458e20dad7e2e13a3120d8ecec75a"}, - {file = "cryptography-38.0.3.tar.gz", hash = "sha256:bfbe6ee19615b07a98b1d2287d6a6073f734735b49ee45b11324d85efc4d5cbd"}, + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965"}, + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4"}, + {file = "cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"}, + {file = "cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5caeb8188c24888c90b5108a441c106f7faa4c4c075a2bcae438c6e8ca73cef"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4789d1e3e257965e960232345002262ede4d094d1a19f4d3b52e48d4d8f3b885"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a"}, + {file = "cryptography-39.0.1.tar.gz", hash = "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695"}, ] [package.dependencies] cffi = ">=1.12" [package.extras] -docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +pep8test = ["black", "check-manifest", "mypy", "ruff", "types-pytz", "types-requests"] sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] +test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist", "pytz"] +test-randomorder = ["pytest-randomly"] +tox = ["tox"] [[package]] name = "decorator" @@ -1671,18 +1670,18 @@ plugins = ["importlib-metadata"] [[package]] name = "pyopenssl" -version = "22.1.0" +version = "23.0.0" description = "Python wrapper module around the OpenSSL library" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "pyOpenSSL-22.1.0-py3-none-any.whl", hash = "sha256:b28437c9773bb6c6958628cf9c3bebe585de661dba6f63df17111966363dd15e"}, - {file = "pyOpenSSL-22.1.0.tar.gz", hash = "sha256:7a83b7b272dd595222d672f5ce29aa030f1fb837630ef229f62e72e395ce8968"}, + {file = "pyOpenSSL-23.0.0-py3-none-any.whl", hash = "sha256:df5fc28af899e74e19fccb5510df423581047e10ab6f1f4ba1763ff5fde844c0"}, + {file = "pyOpenSSL-23.0.0.tar.gz", hash = "sha256:c1cc5f86bcacefc84dada7d31175cae1b1518d5f60d3d0bb595a67822a868a6f"}, ] [package.dependencies] -cryptography = ">=38.0.0,<39" +cryptography = ">=38.0.0,<40" [package.extras] docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] From f110eb0ea864a7115f7ed1d24e868aafb6c038f2 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 22 Feb 2023 11:10:34 +0100 Subject: [PATCH 942/980] fix(tracking): fix automatic unreject when bulk updating Unreject reports when task has changed while bulk updating. --- timed/tracking/tests/test_report.py | 32 +++++++++++++++++++++++++++++ timed/tracking/views.py | 8 +++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 760ccd3b9..2cbe0b41d 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1810,6 +1810,38 @@ def test_report_automatic_unreject(internal_employee_client, report_factory, tas assert not report.rejected +def test_report_bulk_automatic_unreject( + internal_employee_client, user_factory, report_factory, task +): + reviewer = internal_employee_client.user + + user = user_factory.create() + + report = report_factory.create(user=user, rejected=True) + ProjectAssigneeFactory.create( + user=reviewer, project=report.task.project, is_reviewer=True + ) + + url = reverse("report-bulk") + + data = { + "data": { + "type": "report-bulks", + "id": None, + "relationships": { + "task": {"data": {"type": "tasks", "id": task.id}}, + }, + } + } + + query_params = f"?editable=1&reviewer={reviewer.id}&id={report.id}" + response = internal_employee_client.post(url + query_params, data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + report.refresh_from_db() + assert not report.rejected + + @pytest.mark.parametrize( "is_external, remaining_effort_active, is_superuser, expected", [ diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 46bca34da..a21318427 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -263,16 +263,18 @@ def bulk(self, request): ) if "task" in fields: + # unreject report if task has changed + fields["rejected"] = False if fields["task"].project.billed: fields["billed"] = fields["task"].project.billed if fields: - if "rejected" in fields: + # send notification if report was rejected + if fields.get("rejected"): tasks.notify_user_rejected_reports(queryset, fields, user) - queryset.update(**fields) else: tasks.notify_user_changed_reports(queryset, fields, user) - queryset.update(**fields) + queryset.update(**fields) return Response(status=status.HTTP_204_NO_CONTENT) From 79f380926caf6f77362e64add517c780a8267ba4 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 27 Feb 2023 14:42:09 +0100 Subject: [PATCH 943/980] chore: release v3.0.0 --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1331ced4..2a34e43a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +# v3.0.0 + +### Feature +* **filters:** Add number multi value filter ([`4688e41`](https://github.com/adfinis/timed-backend/commit/4688e41da5300d789ed50bdd6af34d7d481767c8)) +* **redmine:** Add pretend mode to redmine commands ([`abc5083`](https://github.com/adfinis/timed-backend/commit/abc50834feb3f84d3018abaa31073ab68e79dd76)) +* **notifications:** Project budget check notifications ([`b81e28e`](https://github.com/adfinis/timed-backend/commit/b81e28e9d0b8386e54caf57b90960e392d5811c0)) +* **statistics:** Show amount offered and invoiced in project statistics ([`144444b`](https://github.com/adfinis/timed-backend/commit/144444b298f2139f44a8ca291ca34ccfb3f66899)) +* **redmine:** Import project expenditure from redmine ([`766f79b`](https://github.com/adfinis/timed-backend/commit/766f79bc17ce927a05217e9bacc18c478404e6f6)) +* **redmine:** Update expenditures on redmine projects ([`0aa9da6`](https://github.com/adfinis/timed-backend/commit/0aa9da69e8432a4d9537b65dd935bebe23fd4c72)) +* **filters:** Allow filtering of tasks and reports in statistics ([`b5b9c8d`](https://github.com/adfinis/timed-backend/commit/b5b9c8d633a4d6fc8633594349feec7ee59fb8d0)) +* **employment:** Add is_external filter for user endpoint ([`8a1b272`](https://github.com/adfinis/timed-backend/commit/8a1b2723147c9775cb06abd090ec307610a2d254)) +* **admin:** Add searchable dropdowns for user lists in admin ([`4c01054`](https://github.com/adfinis/timed-backend/commit/4c010542ce3b3544c3e87f1dd2ca7ff8ec4df245)) +* Track remaining effort on tasks ([`3d045f2`](https://github.com/adfinis/timed-backend/commit/3d045f21ed7fd2147b49c6190dc3e1474c69decb)) +* **tracking:** Reject reports ([`a4e8983`](https://github.com/adfinis/timed-backend/commit/a4e8983265d0b87101a6151982fbb8a802e4cd9a)) + +### Fix +* **tracking:** Fix automatic unreject when bulk updating ([`f110eb0`](https://github.com/adfinis/timed-backend/commit/f110eb0ea864a7115f7ed1d24e868aafb6c038f2)) +* **tracking:** Fix remaining effort on report creation ([`abceb32`](https://github.com/adfinis/timed-backend/commit/abceb322e042df5c34b04e685c331527848c898f)) +* **tracking:** Fix setting of remaining effort ([`16f1dbb`](https://github.com/adfinis/timed-backend/commit/16f1dbb54f625a8468fd33066a685ac1cfae7fec)) +* **notifications:** Omit projects with no reports ([`91a6dd5`](https://github.com/adfinis/timed-backend/commit/91a6dd5ec2d128d6df3994c78eebbb295ec9a2f5)) +* **tracking:** Allow null values on remaining effort for reports ([`08a5aa4`](https://github.com/adfinis/timed-backend/commit/08a5aa429eac6d25cec0699a42919ee8f959ed12)) +* **tracking:** Fix absence for users with multiple employments ([`d884ef6`](https://github.com/adfinis/timed-backend/commit/d884ef6a4463e5095fcecc6cd999aa6b595f5530)) +* Add missing rejected field to ReportIntersectionSerializer ([`ee8f79a`](https://github.com/adfinis/timed-backend/commit/ee8f79a1a724763bdd51222010f47ec40ef71622)) +* **auth:** Let failing auth requests return 401 ([`8454601`](https://github.com/adfinis/timed-backend/commit/8454601019f33272a39814ac8e3fe033c758e7e7)) +* **dev:** Remove deprecated flag from pre-commit isort ([`50e5da2`](https://github.com/adfinis/timed-backend/commit/50e5da2ad5ef12098e0128ba907ac40ac2fa1773)) +* **tracking:** Fix remaining effort check when creating report ([`fc7c92c`](https://github.com/adfinis/timed-backend/commit/fc7c92cf0f3cb937100616abb24bd06804408a51)) +* **statistics:** Add missing fields for project and task statistics ([`89fb718`](https://github.com/adfinis/timed-backend/commit/89fb718901f41914323a60d99a2983ba0454daa0)) +* **reports:** Fix project and customer statistics ([`a3ab8ac`](https://github.com/adfinis/timed-backend/commit/a3ab8acb5be4107ea7f4f6677cdbdb57dd0b95c2)) +* **projects:** Ignore signal when loading a fixture ([`21e5dd7`](https://github.com/adfinis/timed-backend/commit/21e5dd7861a52793cf4b40e94c04de78a64ca3ec)) +* **container:** Executable bit for cmd.sh ([`34f2751`](https://github.com/adfinis/timed-backend/commit/34f27517c896577ddca3e1355cdc3ba5b8233d29)) +* **filters:** Allow Q filtering for MultiQS querysets ([`b629c9d`](https://github.com/adfinis/timed-backend/commit/b629c9d97cec7d4779baaa94f6eb628b394a3c53)) +* **reports:** Refactor statistics ([`21d3677`](https://github.com/adfinis/timed-backend/commit/21d36774816467977f6a45bab0641d7abf4d6ec5)) + + # v2.0.0 ### Breaking diff --git a/pyproject.toml b/pyproject.toml index 507d5aafe..682867aca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "2.0.0" +version = "3.0.0" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From a8994c81bd9c63408b1191d1adcd8c043c4f4bdb Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 27 Feb 2023 19:52:28 +0100 Subject: [PATCH 944/980] chore(reports): disable project and customer statistics --- timed/reports/tests/test_customer_statistic.py | 3 +++ timed/reports/tests/test_project_statistic.py | 2 ++ timed/reports/urls.py | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 871786740..7a9157c3b 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -10,6 +10,7 @@ from timed.tracking.factories import ReportFactory +@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "is_employed, is_customer_assignee, is_customer, expected, status_code", [ @@ -71,6 +72,7 @@ def test_customer_statistic_list( assert json["meta"]["total-time"] == "07:00:00" +@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "filter, expected_result", [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], @@ -114,6 +116,7 @@ def test_customer_statistic_filtered(auth_client, filter, expected_result): assert json["meta"]["total-time"] == f"{expected_result:02}:00:00" +@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "is_employed, expected, status_code", [ diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index d521ae052..fe8590301 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -10,6 +10,7 @@ from timed.tracking.factories import ReportFactory +@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "is_employed, is_customer_assignee, is_customer, expected, status_code", [ @@ -104,6 +105,7 @@ def test_project_statistic_list( assert json["meta"]["total-time"] == "09:00:00" +@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "filter, expected_result", [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 0ee974648..93e37c077 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -10,7 +10,7 @@ r.register(r"month-statistics", views.MonthStatisticViewSet, "month-statistic") r.register(r"task-statistics", views.TaskStatisticViewSet, "task-statistic") r.register(r"user-statistics", views.UserStatisticViewSet, "user-statistic") -r.register(r"customer-statistics", views.CustomerStatisticViewSet, "customer-statistic") -r.register(r"project-statistics", views.ProjectStatisticViewSet, "project-statistic") +# r.register(r"customer-statistics", views.CustomerStatisticViewSet, "customer-statistic") +# r.register(r"project-statistics", views.ProjectStatisticViewSet, "project-statistic") urlpatterns = r.urls From 9c47123af4cab3ab2095b9ff1b0e63ca973ee6ac Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 16:09:04 +0100 Subject: [PATCH 945/980] fix(makefile): use aliases for debug backend Without this, the debug backend won't register properly with the proxy, causing HTTP/502 errors --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7657fd5b7..8d9430ffc 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ migrate: ## Migrate django .PHONY: debug-backend debug-backend: ## Start backend container with service ports for debugging - @docker-compose run --service-ports backend + @docker-compose run --use-aliases --service-ports backend .PHONY: flush flush: ## Flush database contents From 8b6d2150dd5ab0e36d7e9597fdc60e7f56ab3bcc Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 16:09:45 +0100 Subject: [PATCH 946/980] chore: use correct frontend image reference We moved docker image hosting from dockerhub to github container registry (ghcr.io) --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 583ee93dc..6c364ba10 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -21,7 +21,7 @@ services: - timed.local frontend: - image: adfinissygroup/timed-frontend:latest + image: ghcr.io/adfinis/timed-frontend:latest ports: - 4200:80 environment: From 345b8df559593b03f69dcc1b81c590a4277d8fda Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 16:11:15 +0100 Subject: [PATCH 947/980] fix(statistics): refactor multiqs to use filtering aggregates Filtering aggregates are much easier to manage, and can be constructed using way simpler queries. This improves performance significantly. The resulting query is now not a multi-level-nested-subquery that needs to be joined in awkward ways and causing headaches to the PostgreSQL query planner and optimizer. Multi-minute queries now take a fraction of a second. --- timed/reports/filters.py | 194 +++++++++++++++++++++++++----------- timed/reports/views.py | 205 +++++++++++++++------------------------ 2 files changed, 216 insertions(+), 183 deletions(-) diff --git a/timed/reports/filters.py b/timed/reports/filters.py index f237f5677..c14c7b8cb 100644 --- a/timed/reports/filters.py +++ b/timed/reports/filters.py @@ -1,4 +1,4 @@ -from django.db.models import Q +from django.db.models import F, Q, Sum from django_filters.rest_framework import ( BaseInFilter, DateFilter, @@ -6,73 +6,157 @@ NumberFilter, ) -from timed.projects.models import CustomerAssignee, ProjectAssignee, Task, TaskAssignee +from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee -class MultiQSFilterMixin: - def filter_queryset(self, queryset): - qs = super().filter_queryset(queryset) - return qs._finalize() - - -class TaskStatisticFilterSet(MultiQSFilterMixin, FilterSet): - """Filter set for the customer, project and task statistic endpoint.""" - - id = BaseInFilter() - from_date = DateFilter(field_name="reports__date", lookup_expr="gte") - to_date = DateFilter(field_name="reports__date", lookup_expr="lte") - project = NumberFilter(field_name="project") - customer = NumberFilter(field_name="project__customer") - review = NumberFilter(field_name="reports__review") - editable = NumberFilter(method="filter_editable") - not_billable = NumberFilter(field_name="reports__not_billable") - billed = NumberFilter(field_name="reports__billed") - verified = NumberFilter( - field_name="reports__verified_by_id", lookup_expr="isnull", exclude=True - ) - reviewer = NumberFilter(method="filter_has_reviewer") - verifier = NumberFilter(field_name="reports__verified_by") - billing_type = NumberFilter(field_name="project__billing_type") - user = NumberFilter(field_name="reports__user_id") - cost_center = NumberFilter(method="filter_cost_center") - rejected = NumberFilter(field_name="reports__rejected") - +class StatisticFiltersetBase: def filter_has_reviewer(self, queryset, name, value): if not value: # pragma: no cover return queryset - return queryset.filter( - Q( - project__customer_id__in=CustomerAssignee.objects.filter( - is_reviewer=True, user_id=value - ).values("customer_id") - ) - | Q( - project_id__in=ProjectAssignee.objects.filter( - is_reviewer=True, user_id=value - ).values("project_id") - ) - | Q( - id__in=TaskAssignee.objects.filter( - is_reviewer=True, user_id=value - ).values("task_id") - ) + task_prefix = self._refs["task_prefix"] + project_prefix = self._refs["project_prefix"] + customer_prefix = self._refs["customer_prefix"] + + customer_assignees = CustomerAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("customer_id") + + project_assignees = ProjectAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("project_id") + task_assignees = TaskAssignee.objects.filter( + is_reviewer=True, user_id=value + ).values("task_id") + + the_filter = ( + Q(**{f"{customer_prefix}pk__in": customer_assignees}) + | Q(**{f"{project_prefix}pk__in": project_assignees}) + | Q(**{f"{task_prefix}id__in": task_assignees}) ) + return queryset.filter_aggregate(the_filter).filter_base(the_filter) def filter_cost_center(self, queryset, name, value): """ Filter report by cost center. - Cost center on task has higher priority over project cost - center. + The filter behaves slightly different depending on what the + statistic summarizes: + * When viewing the statistic over customers, the work durations are + filtered (either project or task) + * When viewing the statistic over project, only the projects + are filtered + * When viewing the statistic over tasks, only the tasks + are filtered """ - return queryset.filter( - Q(cost_center=value) - | Q(project__cost_center=value) & Q(cost_center__isnull=True) + + # TODO Discuss: Is this the desired behaviour by our users? + + if not value: # pragma: no cover + return queryset + + is_customer = not self._refs["customer_prefix"] + + task_prefix = self._refs["task_prefix"] + project_prefix = self._refs["project_prefix"] + + filter_q = Q(**{f"{task_prefix}cost_center": value}) | Q( + **{ + f"{project_prefix}cost_center": value, + f"{task_prefix}cost_center__isnull": True, + } ) - class Meta: - """Meta information for the task statistic filter set.""" + if is_customer: + # Customer mode: We only need to filter aggregates, + # as the customer has no cost center + return queryset.filter_aggregate(filter_q) + else: + # Project or task: Filter both to get the correct result + return queryset.filter_base(filter_q).filter_aggregate(filter_q) + + def filter_queryset(self, queryset): - model = Task - fields = ["most_recent_remaining_effort"] + qs = super().filter_queryset(queryset) + + duration_ref = self._refs["reports_ref"] + "__duration" + + full_qs = qs._base.annotate( + duration=Sum(duration_ref, filter=qs._agg_filters), pk=F("id") + ) + result = full_qs.values() + # Useful for QS debugging + # print(result.query) + return result + + +def StatisticFiltersetBuilder(name, reports_ref, project_ref, customer_ref, task_ref): + reports_prefix = f"{reports_ref}__" if reports_ref else "" + project_prefix = f"{project_ref}__" if project_ref else "" + customer_prefix = f"{customer_ref}__" if customer_ref else "" + task_prefix = f"{task_ref}__" if task_ref else "" + + return type( + name, + (StatisticFiltersetBase, FilterSet), + { + "_refs": { + "reports_prefix": reports_prefix, + "project_prefix": project_prefix, + "customer_prefix": customer_prefix, + "task_prefix": task_prefix, + "reports_ref": reports_ref, + "project_ref": project_ref, + "customer_ref": customer_ref, + "task_ref": task_ref, + }, + "from_date": DateFilter( + field_name=f"{reports_prefix}date", lookup_expr="gte" + ), + "to_date": DateFilter( + field_name=f"{reports_prefix}date", lookup_expr="lte" + ), + "project": NumberFilter(field_name=f"{project_prefix}pk"), + "customer": NumberFilter(field_name=f"{customer_prefix}pk"), + "review": NumberFilter(field_name=f"{reports_prefix}review"), + "not_billable": NumberFilter(field_name=f"{reports_prefix}not_billable"), + "billed": NumberFilter(field_name=f"{reports_prefix}billed"), + "verified": NumberFilter( + field_name=f"{reports_prefix}verified_by_id", + lookup_expr="isnull", + exclude=True, + ), + "verifier": NumberFilter(field_name=f"{reports_prefix}verified_by"), + "billing_type": NumberFilter(field_name=f"{project_prefix}billing_type"), + "user": NumberFilter(field_name=f"{reports_prefix}user_id"), + "rejected": NumberFilter(field_name=f"{reports_prefix}rejected"), + "id": BaseInFilter(), + "cost_center": NumberFilter(method="filter_cost_center"), + "reviewer": NumberFilter(method="filter_has_reviewer"), + }, + ) + + +CustomerStatisticFilterSet = StatisticFiltersetBuilder( + "CustomerStatisticFilterSet", + reports_ref="projects__tasks__reports", + project_ref="projects", + task_ref="projects__tasks", + customer_ref="", +) + +ProjectStatisticFilterSet = StatisticFiltersetBuilder( + "ProjectStatisticFilterSet", + reports_ref="tasks__reports", + project_ref="", + task_ref="tasks", + customer_ref="customer", +) + +TaskStatisticFilterSet = StatisticFiltersetBuilder( + "TaskStatisticFilterSet", + reports_ref="reports", + project_ref="project", + task_ref="", + customer_ref="project__customer", +) diff --git a/timed/reports/views.py b/timed/reports/views.py index e34f40c77..c9a98e5c5 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -5,7 +5,7 @@ from zipfile import ZipFile from django.conf import settings -from django.db.models import DurationField, F, OuterRef, QuerySet, Subquery, Sum +from django.db.models import F, Q, QuerySet, Sum from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponse from ezodf import Cell, opendoc @@ -17,11 +17,12 @@ from timed.permissions import IsAuthenticated, IsInternal, IsSuperUser from timed.projects.models import Customer, Project, Task from timed.reports import serializers -from timed.reports.filters import TaskStatisticFilterSet from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report from timed.tracking.views import ReportViewSet +from . import filters + class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" @@ -70,11 +71,79 @@ def get_queryset(self): return queryset +class StatisticQueryset(QuerySet): + def __init__(self, catch_prefixes, *args, base_qs=None, agg_filters=None, **kwargs): + super().__init__(*args, **kwargs) + if base_qs is None: + base_qs = self.model.objects.all() + self._base = base_qs + self._agg_filters = agg_filters + self._catch_prefixes = catch_prefixes + + def filter(self, *args, **kwargs): + + if args: # pragma: no cover + # This is a check against programming errors, no need to test + raise RuntimeError( + "Unable to detect statistics filter type form Q objects. use " + "filter_aggregate() or filter_base() instead" + ) + my_filters = { + k: v for k, v in kwargs.items() if not k.startswith(self._catch_prefixes) + } + + agg_filters = { + k: v for k, v in kwargs.items() if k.startswith(self._catch_prefixes) + } + + new_qs = self + if my_filters: + new_qs = self.filter_base(**my_filters) + if agg_filters: + new_qs = new_qs.filter_aggregate(**agg_filters) + + return new_qs + + def filter_base(self, *args, **kwargs): + return StatisticQueryset( + model=self.model, + base_qs=self._base.filter(*args, **kwargs), + catch_prefixes=self._catch_prefixes, + agg_filters=self._agg_filters, + ) + + def _clone(self): + return StatisticQueryset( + model=self.model, + base_qs=self._base._clone(), + catch_prefixes=self._catch_prefixes, + agg_filters=self._agg_filters, + ) + + def __str__(self): + return f"StatisticQueryset({str(self._base)} | {str(self._agg_filters)})" + + def __repr__(self): + return f"StatisticQueryset({repr(self._base)} | {repr(self._agg_filters)})" + + def filter_aggregate(self, *args, **kwargs): + filter_q = Q(*args, **kwargs) + + new_filters = self._agg_filters & filter_q if self._agg_filters else filter_q + + return StatisticQueryset( + model=self.model, + base_qs=self._base, + catch_prefixes=self._catch_prefixes, + agg_filters=new_filters, + ) + + class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Customer statistics calculates total reported time per customer.""" serializer_class = serializers.CustomerStatisticSerializer - filterset_class = TaskStatisticFilterSet + filterset_class = filters.CustomerStatisticFilterSet ordering_fields = ("name", "duration") ordering = ("name",) permission_classes = [ @@ -84,21 +153,14 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = MultiQS( - start=Customer, - annotations={ - "customer_id": F("pk"), - "name": F("name"), - }, - ) - return queryset + return StatisticQueryset(model=Customer, catch_prefixes="projects__") class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Project statistics calculates total reported time per project.""" serializer_class = serializers.ProjectStatisticSerializer - filterset_class = TaskStatisticFilterSet + filterset_class = filters.ProjectStatisticFilterSet ordering_fields = ("name", "duration") ordering = ("name",) permission_classes = [ @@ -108,27 +170,14 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = MultiQS( - start=Project, - annotations={ - "name": F("name"), - "amount_offered": F("amount_offered"), - "amount_offered_currency": F("amount_offered_currency"), - "amount_invoiced": F("amount_invoiced"), - "amount_invoiced_currency": F("amount_invoiced_currency"), - "customer": F("customer"), - "estimated_time": F("estimated_time"), - "total_remaining_effort": F("total_remaining_effort"), - }, - ) - return queryset + return StatisticQueryset(model=Project, catch_prefixes="tasks__") class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Task statistics calculates total reported time per task.""" serializer_class = serializers.TaskStatisticSerializer - filterset_class = TaskStatisticFilterSet + filterset_class = filters.TaskStatisticFilterSet ordering_fields = ("name", "duration") ordering = ("name",) permission_classes = [ @@ -138,107 +187,7 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ] def get_queryset(self): - queryset = MultiQS( - start=Task, - annotations={ - "name": F("name"), - "project": F("project"), - "estimated_time": F("estimated_time"), - "most_recent_remaining_effort": F("most_recent_remaining_effort"), - }, - ) - return queryset - - -class MultiQS(QuerySet): - def __init__(self, start, annotations): - self.model = Task # just to make filterset happy - - self._start = start - - self._tasks = Task.objects.all() - self._reports = Report.objects.all() - - self._annotations = annotations - - def _clone(self): - new_self = MultiQS( - start=self._start, - annotations=self._annotations, - ) - new_self._tasks = self._tasks - new_self._reports = self._reports - - return new_self - - def _validate_q(self, q_object): - if isinstance(q_object, tuple): - assert not q_object[0].startswith( - "reports__" - ), "Filtering of reports not possible" - else: - for child in q_object.children: - self._validate_q(child) - - def _apply(self, method, *args, **kwargs): - for arg in args: - self._validate_q(arg) - - new_self = self._clone() - task_filters = {} - report_filters = {} - for kw, val in kwargs.items(): - if kw.startswith("reports__"): - kw = kw.replace("reports__", "") - report_filters[kw] = val - else: - task_filters[kw] = val - - if report_filters: - new_self._reports = getattr(new_self._reports, method)(**report_filters) - if task_filters: - new_self._tasks = getattr(new_self._tasks, method)(**task_filters) - if args: - new_self._tasks = getattr(new_self._tasks, method)(*args) - return new_self - - def filter(self, *args, **kwargs): - return self._apply("filter", *args, **kwargs) - - def exclude(self, *args, **kwargs): # pragma: no cover - return self._apply("exclude", *args, **kwargs) - - def all(self): - return self._clone() - - def _finalize(self): - task_lookup = { - "Customer": "projects__tasks__id__in", - "Project": "tasks__id__in", - "Task": "pk__in", - } - base_qs = self._start.objects.all().values("pk") - task_lookup = task_lookup[self._start.__name__] - - back_ref_lookups = { - "Customer": "task__project__customer_id", - "Project": "task__project_id", - "Task": "task_id", - } - back_ref = back_ref_lookups[self._start.__name__] - - base_qs = base_qs.filter(**{task_lookup: self._tasks.values("pk")}) - - report_qs = ( - self._reports.values(back_ref) - .annotate(report_duration=Sum("duration", output_field=DurationField())) - .filter(**{back_ref: OuterRef("pk")}) - .values("report_duration") - ) - queryset = base_qs.annotate( - duration=Subquery(report_qs), **self._annotations - ).distinct() - return queryset + return StatisticQueryset(model=Task, catch_prefixes="tasks__") class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): From 6e111affbc5f13aaf59715199e2ef0c283b29d6b Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 16:25:19 +0100 Subject: [PATCH 948/980] refactor(statistics): use alternate lookup for foreign keys The way the queryset is returned, we should use the FK_id lookup: The base queryset returns now `customer_id` instead of `customer`. Instead of messing too much with the queryset code, we just check both variants now. --- timed/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/mixins.py b/timed/mixins.py index 38079632d..1d32d83c0 100644 --- a/timed/mixins.py +++ b/timed/mixins.py @@ -76,7 +76,7 @@ def get_serializer(self, data=None, *args, **kwargs): **{ **entry, **{ - field: objects[entry[field]] + field: objects[entry.get(field) or entry.get(f"{field}_id")] for field, objects in prefetch_per_field.items() }, } From fb5a2dc6480936004d90c067161905196aad58e0 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 16:26:52 +0100 Subject: [PATCH 949/980] feat(statistics): support ordering in new queryset wrapper Also add proper allowed ordering fields to the viewsets. This has been missing, so ordering didn't even work before --- timed/reports/views.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/timed/reports/views.py b/timed/reports/views.py index c9a98e5c5..52412dd73 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -144,7 +144,12 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.CustomerStatisticSerializer filterset_class = filters.CustomerStatisticFilterSet - ordering_fields = ("name", "duration") + ordering_fields = [ + "name", + "duration", + "estimated_time", + "remaining_effort", + ] ordering = ("name",) permission_classes = [ # internal employees or super users may read all customer statistics @@ -161,7 +166,12 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.ProjectStatisticSerializer filterset_class = filters.ProjectStatisticFilterSet - ordering_fields = ("name", "duration") + ordering_fields = [ + "name", + "duration", + "estimated_time", + "remaining_effort", + ] ordering = ("name",) permission_classes = [ # internal employees or super users may read all customer statistics @@ -178,7 +188,12 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): serializer_class = serializers.TaskStatisticSerializer filterset_class = filters.TaskStatisticFilterSet - ordering_fields = ("name", "duration") + ordering_fields = [ + "name", + "duration", + "estimated_time", + "remaining_effort", + ] ordering = ("name",) permission_classes = [ # internal employees or super users may read all customer statistics From bb8e8b7933b0856985a2415c9f880b13119930d3 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 16:28:21 +0100 Subject: [PATCH 950/980] chore(api): re-enable statistics views They had horrible performance before so were disabled, but work properly and fast now. --- timed/reports/tests/test_customer_statistic.py | 3 --- timed/reports/tests/test_project_statistic.py | 2 -- timed/reports/urls.py | 4 ++-- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 7a9157c3b..871786740 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -10,7 +10,6 @@ from timed.tracking.factories import ReportFactory -@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "is_employed, is_customer_assignee, is_customer, expected, status_code", [ @@ -72,7 +71,6 @@ def test_customer_statistic_list( assert json["meta"]["total-time"] == "07:00:00" -@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "filter, expected_result", [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], @@ -116,7 +114,6 @@ def test_customer_statistic_filtered(auth_client, filter, expected_result): assert json["meta"]["total-time"] == f"{expected_result:02}:00:00" -@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "is_employed, expected, status_code", [ diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index fe8590301..d521ae052 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -10,7 +10,6 @@ from timed.tracking.factories import ReportFactory -@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "is_employed, is_customer_assignee, is_customer, expected, status_code", [ @@ -105,7 +104,6 @@ def test_project_statistic_list( assert json["meta"]["total-time"] == "09:00:00" -@pytest.mark.xfail(reason="Endpoint disabled") @pytest.mark.parametrize( "filter, expected_result", [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], diff --git a/timed/reports/urls.py b/timed/reports/urls.py index 93e37c077..0ee974648 100644 --- a/timed/reports/urls.py +++ b/timed/reports/urls.py @@ -10,7 +10,7 @@ r.register(r"month-statistics", views.MonthStatisticViewSet, "month-statistic") r.register(r"task-statistics", views.TaskStatisticViewSet, "task-statistic") r.register(r"user-statistics", views.UserStatisticViewSet, "user-statistic") -# r.register(r"customer-statistics", views.CustomerStatisticViewSet, "customer-statistic") -# r.register(r"project-statistics", views.ProjectStatisticViewSet, "project-statistic") +r.register(r"customer-statistics", views.CustomerStatisticViewSet, "customer-statistic") +r.register(r"project-statistics", views.ProjectStatisticViewSet, "project-statistic") urlpatterns = r.urls From 4e086727a1e69e658e5c044930ce1248aa9c1435 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 17:23:29 +0100 Subject: [PATCH 951/980] fix(pytest): ignore "invalid escape sequence" deprecation warning This causes failures in code that we cannot control, so should be ignored --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 682867aca..a4e26bc49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ filterwarnings = [ "ignore:distutils Version classes are deprecated:DeprecationWarning", # deprecation in pytest-freezegun "ignore:django.conf.urls.url().*:django.utils.deprecation.RemovedInDjango40Warning", "ignore:.*is deprecated in favour of new moneyed.l10n.format_money.*", + "ignore:.*invalid escape sequence.*", ] [tool.coverage.run] From c99b5120fce4f3dc8bddf21346eb46bb8ba72239 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 18:04:33 +0100 Subject: [PATCH 952/980] fix(tests): customer statistic test had a missing customer The new statistic code returns all customers, even the ones without reports in the corresponding projects/tasks. Thus, the "empty" customer needs to be in the expected list as well --- timed/conftest.py | 14 +++++++++++-- .../reports/tests/test_customer_statistic.py | 20 ++++++++++++++++++- timed/reports/tests/test_project_statistic.py | 2 +- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/timed/conftest.py b/timed/conftest.py index 31b21912f..1e128ee71 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -148,11 +148,21 @@ def _autoclear_cache(): def setup_customer_and_employment_status( user, is_assignee, is_customer, is_employed, is_external ): + """ + Set up customer and employment status. + + Return a 2-tuple of assignee and employment, if they + were created + """ + assignee = None + employment = None if is_assignee: - projects_factories.CustomerAssigneeFactory.create( + assignee = projects_factories.CustomerAssigneeFactory.create( user=user, is_customer=is_customer ) if is_employed: - employment_factories.EmploymentFactory.create( + + employment = employment_factories.EmploymentFactory.create( user=user, is_external=is_external ) + return assignee, employment diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 871786740..54a2946c7 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -29,8 +29,10 @@ def test_customer_statistic_list( status_code, django_assert_num_queries, ): + user = auth_client.user - setup_customer_and_employment_status( + + assignee, employment = setup_customer_and_employment_status( user=user, is_assignee=is_customer_assignee, is_customer=is_customer, @@ -38,6 +40,11 @@ def test_customer_statistic_list( is_external=False, ) + # Statistics returns all the customers, not only those + # with reports. So we must get this one into the expected + # list as well + third_customer = assignee.customer if assignee else None + report = ReportFactory.create(duration=timedelta(hours=1)) ReportFactory.create(duration=timedelta(hours=2), task=report.task) report2 = ReportFactory.create(duration=timedelta(hours=4)) @@ -67,6 +74,17 @@ def test_customer_statistic_list( }, }, ] + if third_customer: + expected_data.append( + { + "type": "customer-statistics", + "id": str(third_customer.pk), + "attributes": { + "duration": None, + "name": third_customer.name, + }, + } + ) assert json["data"] == expected_data assert json["meta"]["total-time"] == "07:00:00" diff --git a/timed/reports/tests/test_project_statistic.py b/timed/reports/tests/test_project_statistic.py index d521ae052..4345cece9 100644 --- a/timed/reports/tests/test_project_statistic.py +++ b/timed/reports/tests/test_project_statistic.py @@ -130,7 +130,7 @@ def test_project_statistic_filtered(auth_client, filter, expected_result): filter_values = { "from_date": "2022-08-20", # last two reports "customer": str(task_test.project.customer.pk), # first two - "cost_center": str(cost_center.pk), # first two + "cost_center": str(cost_center.pk), # last one "reviewer": str(reviewer.user.pk), # first two } the_filter = {filter: filter_values[filter]} From 8d0d0fd62896652a15ed00b840090cccdc4eaac8 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 28 Feb 2023 09:28:47 +0100 Subject: [PATCH 953/980] fix(tracking): fix report update notifactions Send notifcations about rejected reports only if rejected is True --- timed/tracking/tests/test_report.py | 10 ++++++++-- timed/tracking/views.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 2cbe0b41d..544d703c5 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -718,7 +718,9 @@ def test_report_update_verified_as_non_staff_but_owner( assert response.status_code == status.HTTP_403_FORBIDDEN -def test_report_update_owner(internal_employee_client, report_factory, task_factory): +def test_report_update_owner( + internal_employee_client, report_factory, task_factory, mailoutbox +): """Should update an existing report.""" user = internal_employee_client.user report = report_factory.create(user=user) @@ -731,6 +733,7 @@ def test_report_update_owner(internal_employee_client, report_factory, task_fact "attributes": { "comment": "foobar", "duration": "01:00:00", + "rejected": False, "date": "2017-02-04", }, "relationships": {"task": {"data": {"type": "tasks", "id": task.id}}}, @@ -753,6 +756,7 @@ def test_report_update_owner(internal_employee_client, report_factory, task_fact assert json["data"]["relationships"]["task"]["data"]["id"] == str( data["data"]["relationships"]["task"]["data"]["id"] ) + assert len(mailoutbox) == 0 def test_report_update_date_reviewer( @@ -874,6 +878,7 @@ def test_report_set_verified_by_user( def test_report_update_reviewer( internal_employee_client, report_factory, + mailoutbox, ): user = internal_employee_client.user report = report_factory.create(user=user) @@ -885,7 +890,7 @@ def test_report_update_reviewer( "data": { "type": "reports", "id": report.id, - "attributes": {"comment": "foobar"}, + "attributes": {"comment": "foobar", "rejected": False}, "relationships": { "verified-by": {"data": {"id": user.id, "type": "users"}} }, @@ -896,6 +901,7 @@ def test_report_update_reviewer( response = internal_employee_client.patch(url, data) assert response.status_code == status.HTTP_200_OK + assert len(mailoutbox) == 0 def test_report_update_supervisor( diff --git a/timed/tracking/views.py b/timed/tracking/views.py index a21318427..7bd4fb071 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -173,7 +173,7 @@ def update(self, request, *args, **kwargs): } if fields and request.user != instance.user: tasks.notify_user_changed_report(instance, fields, request.user) - if "rejected" in fields: + if fields.get("rejected"): tasks.notify_user_rejected_report(instance, request.user) return super().update(request, *args, **kwargs) From 757de4e263cd42bb8521bccdd51dc6bf2207e761 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 28 Feb 2023 18:45:41 +0100 Subject: [PATCH 954/980] feat: empty sums in correcr ordering The statistics should report zero hours instead of NULL. This makes the ordering of data actually work as expected --- timed/reports/filters.py | 9 +++++++-- timed/reports/tests/test_customer_statistic.py | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/timed/reports/filters.py b/timed/reports/filters.py index c14c7b8cb..ebe771758 100644 --- a/timed/reports/filters.py +++ b/timed/reports/filters.py @@ -1,4 +1,5 @@ -from django.db.models import F, Q, Sum +from django.db.models import DurationField, F, Q, Sum, Value +from django.db.models.functions import Coalesce from django_filters.rest_framework import ( BaseInFilter, DateFilter, @@ -82,7 +83,11 @@ def filter_queryset(self, queryset): duration_ref = self._refs["reports_ref"] + "__duration" full_qs = qs._base.annotate( - duration=Sum(duration_ref, filter=qs._agg_filters), pk=F("id") + duration=Coalesce( + Sum(duration_ref, filter=qs._agg_filters), + Value("00:00:00", DurationField(null=False)), + ), + pk=F("id"), ) result = full_qs.values() # Useful for QS debugging diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index 54a2946c7..ceb7dac4e 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -75,16 +75,16 @@ def test_customer_statistic_list( }, ] if third_customer: - expected_data.append( + expected_data = [ { "type": "customer-statistics", "id": str(third_customer.pk), "attributes": { - "duration": None, + "duration": "00:00:00", "name": third_customer.name, }, } - ) + ] + expected_data assert json["data"] == expected_data assert json["meta"]["total-time"] == "07:00:00" From cd3b312cc2c51a399c156fa6531df30f5d9c1fa7 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 28 Feb 2023 19:35:12 +0100 Subject: [PATCH 955/980] chore: release v3.0.1 --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a34e43a9..b7bdf764e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# v3.0.1 + +### Feature +* Empty sums in correcr ordering ([`757de4e`](https://github.com/adfinis/timed-backend/commit/757de4e263cd42bb8521bccdd51dc6bf2207e761)) +* **statistics:** Support ordering in new queryset wrapper ([`fb5a2dc`](https://github.com/adfinis/timed-backend/commit/fb5a2dc6480936004d90c067161905196aad58e0)) + +### Fix +* **tracking:** Fix report update notifactions ([`8d0d0fd`](https://github.com/adfinis/timed-backend/commit/8d0d0fd62896652a15ed00b840090cccdc4eaac8)) +* **tests:** Customer statistic test had a missing customer ([`c99b512`](https://github.com/adfinis/timed-backend/commit/c99b5120fce4f3dc8bddf21346eb46bb8ba72239)) +* **pytest:** Ignore "invalid escape sequence" deprecation warning ([`4e08672`](https://github.com/adfinis/timed-backend/commit/4e086727a1e69e658e5c044930ce1248aa9c1435)) +* **statistics:** Refactor multiqs to use filtering aggregates ([`345b8df`](https://github.com/adfinis/timed-backend/commit/345b8df559593b03f69dcc1b81c590a4277d8fda)) +* **makefile:** Use aliases for debug backend ([`9c47123`](https://github.com/adfinis/timed-backend/commit/9c47123af4cab3ab2095b9ff1b0e63ca973ee6ac)) + # v3.0.0 ### Feature diff --git a/pyproject.toml b/pyproject.toml index a4e26bc49..12650aa61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "3.0.0" +version = "3.0.1" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From 6e1f4c89672a991ce1765bbed7c5e71f02a119e2 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 1 Mar 2023 16:35:16 +0100 Subject: [PATCH 956/980] fix(redmine): fix NoneType for amount offered/invoiced for projects Write 0.00 in redmine if the project has no amount offered/invoiced --- .../commands/update_project_expenditure.py | 14 +++++++++++--- .../tests/test_update_project_expenditure.py | 14 ++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/timed/redmine/management/commands/update_project_expenditure.py b/timed/redmine/management/commands/update_project_expenditure.py index 038c537dd..4d0d48ab5 100644 --- a/timed/redmine/management/commands/update_project_expenditure.py +++ b/timed/redmine/management/commands/update_project_expenditure.py @@ -59,6 +59,14 @@ def handle(self, *args, **options): ) continue issue.estimated_hours = estimated_hours + + amount_offered = ( + project.amount_offered and project.amount_offered.amount + ) or "0.00" + amount_invoiced = ( + project.amount_invoiced and project.amount_invoiced.amount + ) or "0.00" + # fields not active in Redmine projects settings won't be saved issue.custom_fields = [ { @@ -67,11 +75,11 @@ def handle(self, *args, **options): }, { "id": settings.REDMINE_AMOUNT_OFFERED_FIELD, - "value": project.amount_offered.amount, + "value": amount_offered, }, { "id": settings.REDMINE_AMOUNT_INVOICED_FIELD, - "value": project.amount_invoiced.amount, + "value": amount_invoiced, }, ] if not pretend: @@ -87,6 +95,6 @@ def handle(self, *args, **options): self.stdout.write( self.style.SUCCESS( - f"Updating Redmine issue {project.redmine_project.issue_id} with total spent hours {total_spent_hours}, amount offered {project.amount_offered.amount}, amount invoiced {project.amount_invoiced.amount}" + f"Updating Redmine issue {project.redmine_project.issue_id} with total spent hours {total_spent_hours}, amount offered {amount_offered}, amount invoiced {amount_invoiced}" ) ) diff --git a/timed/redmine/tests/test_update_project_expenditure.py b/timed/redmine/tests/test_update_project_expenditure.py index aef6c8679..aeebe6ec3 100644 --- a/timed/redmine/tests/test_update_project_expenditure.py +++ b/timed/redmine/tests/test_update_project_expenditure.py @@ -7,8 +7,11 @@ from timed.redmine.models import RedmineProject -@pytest.mark.parametrize("pretend", [False, True]) -def test_update_project_expenditure(db, mocker, capsys, report_factory, pretend): +@pytest.mark.parametrize("pretend", [True, False]) +@pytest.mark.parametrize("amount_offered", [None, 100.00, 0]) +def test_update_project_expenditure( + db, mocker, capsys, report_factory, pretend, amount_offered +): redmine_instance = mocker.MagicMock() issue = mocker.MagicMock() redmine_instance.issue.get.return_value = issue @@ -18,24 +21,27 @@ def test_update_project_expenditure(db, mocker, capsys, report_factory, pretend) report = report_factory(duration=datetime.timedelta(hours=4)) project = report.task.project project.estimated_time = datetime.timedelta(hours=10) + project.amount_offered = amount_offered project.save() RedmineProject.objects.create(project=report.task.project, issue_id=1000) call_command("update_project_expenditure", pretend=pretend) + offered = (project.amount_offered and project.amount_offered.amount) or "0.00" + if not pretend: redmine_instance.issue.get.assert_called_once_with(1000) assert issue.estimated_hours == project.estimated_time.total_seconds() / 3600 assert issue.custom_fields[0]["value"] == report.duration.total_seconds() / 3600 - assert issue.custom_fields[1]["value"] == project.amount_offered.amount + assert issue.custom_fields[1]["value"] == offered assert issue.custom_fields[2]["value"] == project.amount_invoiced.amount issue.save.assert_called_once_with() else: out, _ = capsys.readouterr() assert "Redmine issue 1000" in out assert f"total spent hours {report.duration.total_seconds() / 3600}" in out - assert f"amount offered {project.amount_offered.amount}" in out + assert f"amount offered {offered}" in out assert f"amount invoiced {project.amount_invoiced.amount}" in out From bf79cac32888e439322611425580748d3713ce79 Mon Sep 17 00:00:00 2001 From: Lukas Grossar Date: Thu, 2 Mar 2023 12:01:34 +0100 Subject: [PATCH 957/980] chore(reject template): add further information to reject mail template --- .../tracking/templates/mail/notify_user_rejected_reports.tmpl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl b/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl index 4cd6e9792..7352a64e1 100644 --- a/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl +++ b/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl @@ -1,5 +1,6 @@ {% load tracking_extras %} -Some of your reports have been rejected. +Some of your reports have been rejected by a reviewer. Please get in contact +with them to clarify the reports. Reviewer: {{reviewer.first_name }} {{ reviewer.last_name }} {% for changeset in user_changes %} From 3472e6a8e91a7222f7fee322ed1292d10629feee Mon Sep 17 00:00:00 2001 From: Lukas Grossar Date: Thu, 2 Mar 2023 12:35:02 +0100 Subject: [PATCH 958/980] chore(reject template): add further information to reject mail template Co-authored-by: David Vogt --- .../tracking/templates/mail/notify_user_rejected_reports.tmpl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl b/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl index 7352a64e1..ace805ea4 100644 --- a/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl +++ b/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl @@ -1,6 +1,7 @@ {% load tracking_extras %} Some of your reports have been rejected by a reviewer. Please get in contact -with them to clarify the reports. +with them to clarify the reports. Most likely, you will just need to move +the reports to the correct project / task. Reviewer: {{reviewer.first_name }} {{ reviewer.last_name }} {% for changeset in user_changes %} From 43edfc7135a32887bae10aa7e33e53bc8edebcbf Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 2 Mar 2023 14:36:53 +0100 Subject: [PATCH 959/980] chore: release v3.0.2 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bdf764e..e6ec24765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v3.0.2 + +### Fix +* **redmine:** Fix NoneType for amount offered/invoiced for projects ([`6e1f4c8`](https://github.com/adfinis/timed-backend/commit/6e1f4c89672a991ce1765bbed7c5e71f02a119e2)) + # v3.0.1 ### Feature diff --git a/pyproject.toml b/pyproject.toml index 12650aa61..297e5caf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "3.0.1" +version = "3.0.2" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From 5f6bc532c6e0d76c9dae07b423afa6ea7c2ab52c Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 14 Mar 2023 17:27:44 +0100 Subject: [PATCH 960/980] fix(redmine): fix value check for custom fields --- timed/redmine/management/commands/import_project_data.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/timed/redmine/management/commands/import_project_data.py b/timed/redmine/management/commands/import_project_data.py index d65270ac9..98735c95b 100644 --- a/timed/redmine/management/commands/import_project_data.py +++ b/timed/redmine/management/commands/import_project_data.py @@ -58,14 +58,10 @@ def handle(self, *args, **options): ) timed_project.amount_offered = ( - amount_offered["value"] - if amount_offered != "" - else timed_project.amount_offered + amount_offered.get("value") or timed_project.amount_offered ) timed_project.amount_invoiced = ( - amount_invoiced["value"] - if amount_invoiced != "" - else timed_project.amount_invoiced + amount_invoiced.get("value") or timed_project.amount_invoiced ) if not pretend: timed_project.save() From 41611b38cb2db43138092d27a3e8d018b648fe56 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 15 Mar 2023 14:26:48 +0100 Subject: [PATCH 961/980] chore: release v3.0.3 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ec24765..603848f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v3.0.3 + +### Fix +* **redmine:** Fix value check for custom fields ([`5f6bc53`](https://github.com/adfinis/timed-backend/commit/5f6bc532c6e0d76c9dae07b423afa6ea7c2ab52c)) + # v3.0.2 ### Fix diff --git a/pyproject.toml b/pyproject.toml index 297e5caf3..fc55f0cc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "3.0.2" +version = "3.0.3" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From 7f0ebab669ba067295df8325a34d36e664d0b1ee Mon Sep 17 00:00:00 2001 From: Falk Date: Fri, 24 Mar 2023 16:59:50 +0100 Subject: [PATCH 962/980] chore(dockerfile): pin wait-for-it version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 15d492723..412614b09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.9 WORKDIR /app -RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -P /usr/local/bin \ +RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/81b1373/wait-for-it.sh -P /usr/local/bin \ && chmod +x /usr/local/bin/wait-for-it.sh RUN apt-get update && apt-get install -y --no-install-recommends \ From fc1f631a7fcdc25ab93b5cbcf38845f30af3f4a5 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 3 Apr 2023 15:59:02 +0200 Subject: [PATCH 963/980] fix(redmine): log estimated_hours in update_project_expenditure command --- timed/redmine/management/commands/update_project_expenditure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/redmine/management/commands/update_project_expenditure.py b/timed/redmine/management/commands/update_project_expenditure.py index 4d0d48ab5..4076f870c 100644 --- a/timed/redmine/management/commands/update_project_expenditure.py +++ b/timed/redmine/management/commands/update_project_expenditure.py @@ -95,6 +95,6 @@ def handle(self, *args, **options): self.stdout.write( self.style.SUCCESS( - f"Updating Redmine issue {project.redmine_project.issue_id} with total spent hours {total_spent_hours}, amount offered {amount_offered}, amount invoiced {amount_invoiced}" + f"Updating Redmine issue {project.redmine_project.issue_id} with total spent hours {total_spent_hours}, estimated time {estimated_hours}, amount offered {amount_offered}, amount invoiced {amount_invoiced}" ) ) From 2abbcac8543e9a7e8313e2942d6d79b53b090d89 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 4 Apr 2023 15:36:12 +0200 Subject: [PATCH 964/980] chore(docker): install wait-for-it from debian package --- Dockerfile | 5 +---- cmd.sh | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 412614b09..e3a03d7d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,7 @@ FROM python:3.9 WORKDIR /app -RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/81b1373/wait-for-it.sh -P /usr/local/bin \ - && chmod +x /usr/local/bin/wait-for-it.sh - -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN apt-get update && apt-get install -y --no-install-recommends wait-for-it \ libpq-dev \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /app diff --git a/cmd.sh b/cmd.sh index 32af121fa..a83e61a30 100755 --- a/cmd.sh +++ b/cmd.sh @@ -8,6 +8,6 @@ set -x set -e -wait-for-it.sh "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" +wait-for-it "${DJANGO_DATABASE_HOST}":"${DJANGO_DATABASE_PORT}" -t "${WAITFORIT_TIMEOUT}" ./manage.py migrate --no-input ./manage.py serve --static --port 80 --req-queue-len "${HURRICANE_REQ_QUEUE_LEN:-250}" "$@" From 393d5467974ab1efb93189ef25ba6db2b0f4fb4c Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 5 Apr 2023 14:32:49 +0200 Subject: [PATCH 965/980] chore: release v3.0.4 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 603848f5d..8311068ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v3.0.4 + +### Fix +* **redmine:** Log estimated_hours in update_project_expenditure command ([`fc1f631`](https://github.com/adfinis/timed-backend/commit/fc1f631a7fcdc25ab93b5cbcf38845f30af3f4a5)) + # v3.0.3 ### Fix diff --git a/pyproject.toml b/pyproject.toml index fc55f0cc1..d21c14813 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "3.0.3" +version = "3.0.4" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From 6a5d0eda470939c59ad9ea869d4296e0115dd33e Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 3 May 2023 13:34:42 +0200 Subject: [PATCH 966/980] fix(tracking): fix updating own rejected reports and rejecting own reports Don't send emails when updating anything besides task on a rejected report that the user owns. Updating the task on a rejected report should still automatically unreject the report. Don't allow rejection of own reports. --- timed/tracking/serializers.py | 4 +++- timed/tracking/tests/test_report.py | 25 ++++++++++++++++++++++++- timed/tracking/views.py | 22 ++++++++++++---------- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/timed/tracking/serializers.py b/timed/tracking/serializers.py index e2888dde1..65ff17e32 100644 --- a/timed/tracking/serializers.py +++ b/timed/tracking/serializers.py @@ -131,7 +131,9 @@ def validate_rejected(self, value): """Only reviewers are allowed to change rejected field.""" if self.instance is not None: user = self.context["request"].user - if not user.is_reviewer and (self.instance.rejected != value): + if ( + not user.is_reviewer or self.instance.user == user + ) and self.instance.rejected != value: raise ValidationError(_("Only reviewers may reject reports.")) return value diff --git a/timed/tracking/tests/test_report.py b/timed/tracking/tests/test_report.py index 544d703c5..2688bcbcb 100644 --- a/timed/tracking/tests/test_report.py +++ b/timed/tracking/tests/test_report.py @@ -1698,7 +1698,7 @@ def test_report_list_no_employment( @pytest.mark.parametrize( "report_owner, reviewer, expected, mail_count, status_code", [ - (True, True, True, 1, status.HTTP_200_OK), + (True, True, False, 0, status.HTTP_400_BAD_REQUEST), (False, True, True, 1, status.HTTP_200_OK), (True, False, False, 0, status.HTTP_400_BAD_REQUEST), (False, False, False, 0, status.HTTP_403_FORBIDDEN), @@ -1744,6 +1744,29 @@ def test_report_reject( assert mail.to[0] == user2.email if not report_owner else user +def test_report_update_rejected_owner( + internal_employee_client, report_factory, mailoutbox +): + user = internal_employee_client.user + report = report_factory.create(user=user, rejected=True) + + data = { + "data": { + "type": "reports", + "id": report.id, + "attributes": { + "comment": "foobar", + "rejected": True, + }, + } + } + + url = reverse("report-detail", args=[report.id]) + response = internal_employee_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + assert len(mailoutbox) == 0 + + def test_report_reject_multiple_notify( internal_employee_client, task, diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 7bd4fb071..d4e7ef74b 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -165,16 +165,18 @@ def update(self, request, *args, **kwargs): serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) - fields = { - key: value - for key, value in serializer.validated_data.items() - # value equal None means do not touch - if value is not None - } - if fields and request.user != instance.user: - tasks.notify_user_changed_report(instance, fields, request.user) - if fields.get("rejected"): - tasks.notify_user_rejected_report(instance, request.user) + if request.user != instance.user: + # send a notification only when the user is updating someone else's report + fields = { + key: value + for key, value in serializer.validated_data.items() + # value equal None means do not touch + if value is not None + } + if fields: + tasks.notify_user_changed_report(instance, fields, request.user) + if fields.get("rejected"): + tasks.notify_user_rejected_report(instance, request.user) return super().update(request, *args, **kwargs) From 24c6d19f23fa3cf8c7c0d30a2669cd2c8cb86677 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 8 May 2023 13:31:47 +0200 Subject: [PATCH 967/980] chore(deps): update dependencies and pin django to 3.2.19 --- poetry.lock | 795 ++++++++++++++++++++++++++----------------------- pyproject.toml | 3 +- 2 files changed, 428 insertions(+), 370 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5799d31f1..f2cf499dc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "appnope" @@ -29,54 +29,55 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "asttokens" -version = "2.1.0" +version = "2.2.1" description = "Annotate AST trees with source code positions" category = "dev" optional = false python-versions = "*" files = [ - {file = "asttokens-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b28ed85e254b724439afc783d4bee767f780b936c3fe8b3275332f42cf5f561"}, - {file = "asttokens-2.1.0.tar.gz", hash = "sha256:4aa76401a151c8cc572d906aad7aea2a841780834a19d780f4321c0fe1b54635"}, + {file = "asttokens-2.2.1-py2.py3-none-any.whl", hash = "sha256:6b0ac9e93fb0335014d382b8fa9b3afa7df546984258005da0b9e7095b3deb1c"}, + {file = "asttokens-2.2.1.tar.gz", hash = "sha256:4622110b2a6f30b77e1473affaa97e711bc2f07d3f10848420ff1898edbe94f3"}, ] [package.dependencies] six = "*" [package.extras] -test = ["astroid (<=2.5.3)", "pytest"] +test = ["astroid", "pytest"] [[package]] name = "attrs" -version = "22.1.0" +version = "23.1.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" files = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, ] [package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] -tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] [[package]] name = "babel" -version = "2.11.0" +version = "2.12.1" description = "Internationalization utilities" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, - {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, + {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, + {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, ] [package.dependencies] -pytz = ">=2015.7" +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [[package]] name = "backcall" @@ -139,14 +140,14 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2022.12.7" +version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, + {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, ] [[package]] @@ -228,31 +229,101 @@ pycparser = "*" [[package]] name = "chardet" -version = "5.0.0" +version = "5.1.0" description = "Universal encoding detector for Python 3" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "chardet-5.0.0-py3-none-any.whl", hash = "sha256:d3e64f022d254183001eccc5db4040520c0f23b1a3f33d6413e099eb7f126557"}, - {file = "chardet-5.0.0.tar.gz", hash = "sha256:0368df2bfd78b5fc20572bb4e9bb7fb53e2c094f60ae9993339e8671d0afb8aa"}, + {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, + {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, ] [[package]] name = "charset-normalizer" -version = "2.1.1" +version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = ">=3.6.0" -files = [ - {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, - {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, + {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, ] -[package.extras] -unicode-backport = ["unicodedata2"] - [[package]] name = "click" version = "8.1.3" @@ -339,35 +410,31 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "39.0.1" +version = "40.0.2" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965"}, - {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106"}, - {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c"}, - {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4"}, - {file = "cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"}, - {file = "cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5caeb8188c24888c90b5108a441c106f7faa4c4c075a2bcae438c6e8ca73cef"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4789d1e3e257965e960232345002262ede4d094d1a19f4d3b52e48d4d8f3b885"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a"}, - {file = "cryptography-39.0.1.tar.gz", hash = "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695"}, + {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:8f79b5ff5ad9d3218afb1e7e20ea74da5f76943ee5edb7f76e56ec5161ec782b"}, + {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05dc219433b14046c476f6f09d7636b92a1c3e5808b9a6536adf4932b3b2c440"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4df2af28d7bedc84fe45bd49bc35d710aede676e2a4cb7fc6d103a2adc8afe4d"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dcca15d3a19a66e63662dc8d30f8036b07be851a8680eda92d079868f106288"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a04386fb7bc85fab9cd51b6308633a3c271e3d0d3eae917eebab2fac6219b6d2"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:adc0d980fd2760c9e5de537c28935cc32b9353baaf28e0814df417619c6c8c3b"}, + {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d5a1bd0e9e2031465761dfa920c16b0065ad77321d8a8c1f5ee331021fda65e9"}, + {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a95f4802d49faa6a674242e25bfeea6fc2acd915b5e5e29ac90a32b1139cae1c"}, + {file = "cryptography-40.0.2-cp36-abi3-win32.whl", hash = "sha256:aecbb1592b0188e030cb01f82d12556cf72e218280f621deed7d806afd2113f9"}, + {file = "cryptography-40.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:b12794f01d4cacfbd3177b9042198f3af1c856eedd0a98f10f141385c809a14b"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:142bae539ef28a1c76794cca7f49729e7c54423f615cfd9b0b1fa90ebe53244b"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:956ba8701b4ffe91ba59665ed170a2ebbdc6fc0e40de5f6059195d9f2b33ca0e"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f01c9863da784558165f5d4d916093737a75203a5c5286fde60e503e4276c7a"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3daf9b114213f8ba460b829a02896789751626a2a4e7a43a28ee77c04b5e4958"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48f388d0d153350f378c7f7b41497a54ff1513c816bcbbcafe5b829e59b9ce5b"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c0764e72b36a3dc065c155e5b22f93df465da9c39af65516fe04ed3c68c92636"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cbaba590180cba88cb99a5f76f90808a624f18b169b90a4abb40c1fd8c19420e"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7a38250f433cd41df7fcb763caa3ee9362777fdb4dc642b9a349721d2bf47404"}, + {file = "cryptography-40.0.2.tar.gz", hash = "sha256:c33c0d32b8594fa647d2e01dbccc303478e16fdd7cf98652d5b3ed11aa5e5c99"}, ] [package.dependencies] @@ -376,10 +443,10 @@ cffi = ">=1.12" [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "check-manifest", "mypy", "ruff", "types-pytz", "types-requests"] +pep8test = ["black", "check-manifest", "mypy", "ruff"] sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist", "pytz"] +test = ["iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist"] test-randomorder = ["pytest-randomly"] tox = ["tox"] @@ -397,14 +464,14 @@ files = [ [[package]] name = "django" -version = "3.2.18" +version = "3.2.19" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "Django-3.2.18-py3-none-any.whl", hash = "sha256:4d492d9024c7b3dfababf49f94511ab6a58e2c9c3c7207786f1ba4eb77750706"}, - {file = "Django-3.2.18.tar.gz", hash = "sha256:08208dfe892eb64fff073ca743b3b952311104f939e7f6dae954fe72dcc533ba"}, + {file = "Django-3.2.19-py3-none-any.whl", hash = "sha256:21cc991466245d659ab79cb01204f9515690f8dae00e5eabde307f14d24d4d7d"}, + {file = "Django-3.2.19.tar.gz", hash = "sha256:031365bae96814da19c10706218c44dff3b654cc4de20a98bd2d29b9bde469f0"}, ] [package.dependencies] @@ -418,14 +485,14 @@ bcrypt = ["bcrypt"] [[package]] name = "django-cors-headers" -version = "3.13.0" +version = "3.14.0" description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "django-cors-headers-3.13.0.tar.gz", hash = "sha256:f9dc6b4e3f611c3199700b3e5f3398c28757dcd559c2f82932687f3d0443cfdf"}, - {file = "django_cors_headers-3.13.0-py3-none-any.whl", hash = "sha256:37e42883b5f1f2295df6b4bba96eb2417a14a03270cb24b2a07f021cd4487cf4"}, + {file = "django_cors_headers-3.14.0-py3-none-any.whl", hash = "sha256:684180013cc7277bdd8702b80a3c5a4b3fcae4abb2bf134dceb9f5dfe300228e"}, + {file = "django_cors_headers-3.14.0.tar.gz", hash = "sha256:5fbd58a6fb4119d975754b2bc090f35ec160a8373f276612c675b00e8a138739"}, ] [package.dependencies] @@ -561,26 +628,26 @@ django = ">=1.4" [[package]] name = "django-nested-inline" -version = "0.4.5" +version = "0.4.6" description = "Recursive nesting of inline forms for Django Admin" category = "main" optional = false python-versions = "*" files = [ - {file = "django-nested-inline-0.4.5.tar.gz", hash = "sha256:d7e51dc1ebf805df53ec7ff9b4108dfe1dfb8a7e02212700a4e467f2caafe34b"}, - {file = "django_nested_inline-0.4.5-py3-none-any.whl", hash = "sha256:fc6998e7d607c1414e25897ae1a544105ada93d63c5cff3ac9582fbf14d8ec63"}, + {file = "django-nested-inline-0.4.6.tar.gz", hash = "sha256:e57b55858d112364dfb112bbcdabb888e581d1677d31c1cac3bdcef6c890dc61"}, + {file = "django_nested_inline-0.4.6-py2.py3-none-any.whl", hash = "sha256:4fc6f0e78b3b5411b4bb7f180bb984831b88874bda48e49a14307baff5da5f12"}, ] [[package]] name = "django-prometheus" -version = "2.2.0" +version = "2.3.1" description = "Django middlewares to monitor your application with Prometheus.io." category = "main" optional = false python-versions = "*" files = [ - {file = "django-prometheus-2.2.0.tar.gz", hash = "sha256:240378a1307c408bd5fc85614a3a57f1ce633d4a222c9e291e2bbf325173b801"}, - {file = "django_prometheus-2.2.0-py2.py3-none-any.whl", hash = "sha256:e6616770d8820b8834762764bf1b76ec08e1b98e72a6f359d488a2e15fe3537c"}, + {file = "django-prometheus-2.3.1.tar.gz", hash = "sha256:f9c8b6c780c9419ea01043c63a437d79db2c33353451347894408184ad9c3e1e"}, + {file = "django_prometheus-2.3.1-py2.py3-none-any.whl", hash = "sha256:cf9b26f7ba2e4568f08f8f91480a2882023f5908579681bcf06a4d2465f12168"}, ] [package.dependencies] @@ -638,14 +705,14 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.0" +version = "1.1.1" description = "Backport of PEP 654 (exception groups)" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, - {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, ] [package.extras] @@ -687,14 +754,14 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "15.3.1" +version = "18.6.2" description = "Faker is a Python package that generates fake data for you." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "Faker-15.3.1-py3-none-any.whl", hash = "sha256:4a3465624515a6807e8aa7e8eeb85bdd86a2fa53de4e258892dd6be95362462e"}, - {file = "Faker-15.3.1.tar.gz", hash = "sha256:b9dd2fd9a9ac68a4e0c5040cd9e9bfaa099fa8dd15bae5f01f224a45431818d5"}, + {file = "Faker-18.6.2-py3-none-any.whl", hash = "sha256:6385386ba8d5aa255bec72f5392c2b795fcec8bebf975a9953488948d54bce35"}, + {file = "Faker-18.6.2.tar.gz", hash = "sha256:ef61bbf266d30819e83bab4a6c74a0f5979ce4d19d4c9305719dd26cb7d8d51c"}, ] [package.dependencies] @@ -871,21 +938,21 @@ files = [ [[package]] name = "importlib-metadata" -version = "5.0.0" +version = "6.6.0" description = "Read metadata from Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, - {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, + {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, + {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] @@ -903,14 +970,14 @@ files = [ [[package]] name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] @@ -932,14 +999,14 @@ toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} [[package]] name = "ipython" -version = "8.6.0" +version = "8.12.2" description = "IPython: Productive Interactive Computing" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "ipython-8.6.0-py3-none-any.whl", hash = "sha256:91ef03016bcf72dd17190f863476e7c799c6126ec7e8be97719d1bc9a78a59a4"}, - {file = "ipython-8.6.0.tar.gz", hash = "sha256:7c959e3dedbf7ed81f9b9d8833df252c430610e2a4a6464ec13cd20975ce20a5"}, + {file = "ipython-8.12.2-py3-none-any.whl", hash = "sha256:ea8801f15dfe4ffb76dea1b09b847430ffd70d827b41735c64a0638a04103bfc"}, + {file = "ipython-8.12.2.tar.gz", hash = "sha256:c7b80eb7f5a855a88efc971fda506ff7a91c280b42cdae26643e0f601ea281ea"}, ] [package.dependencies] @@ -951,13 +1018,14 @@ jedi = ">=0.16" matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} pickleshare = "*" -prompt-toolkit = ">3.0.1,<3.1.0" +prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0" pygments = ">=2.4.0" stack-data = "*" traitlets = ">=5" +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} [package.extras] -all = ["black", "curio", "docrepr", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.20)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +all = ["black", "curio", "docrepr", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] black = ["black"] doc = ["docrepr", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] kernel = ["ipykernel"] @@ -967,7 +1035,7 @@ notebook = ["ipywidgets", "notebook"] parallel = ["ipyparallel"] qtconsole = ["qtconsole"] test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] -test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.20)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] [[package]] name = "isort" @@ -989,22 +1057,23 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] [[package]] name = "jedi" -version = "0.18.1" +version = "0.18.2" description = "An autocompletion tool for Python that can be used for text editors." category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, - {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, + {file = "jedi-0.18.2-py2.py3-none-any.whl", hash = "sha256:203c1fd9d969ab8f2119ec0a3342e0b49910045abe6af0a3ae83a5764d54639e"}, + {file = "jedi-0.18.2.tar.gz", hash = "sha256:bae794c30d07f6d910d32a7048af09b5a39ed740918da923c6b780790ebac612"}, ] [package.dependencies] parso = ">=0.8.0,<0.9.0" [package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] -testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] +testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] [[package]] name = "josepy" @@ -1042,82 +1111,89 @@ files = [ [[package]] name = "lxml" -version = "4.9.1" +version = "4.9.2" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" files = [ - {file = "lxml-4.9.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed"}, - {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc"}, - {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc"}, - {file = "lxml-4.9.1-cp27-cp27m-win32.whl", hash = "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3"}, - {file = "lxml-4.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627"}, - {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84"}, - {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8"}, - {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8"}, - {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d"}, - {file = "lxml-4.9.1-cp310-cp310-win32.whl", hash = "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7"}, - {file = "lxml-4.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b"}, - {file = "lxml-4.9.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d"}, - {file = "lxml-4.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3"}, - {file = "lxml-4.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29"}, - {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d"}, - {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318"}, - {file = "lxml-4.9.1-cp35-cp35m-win32.whl", hash = "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7"}, - {file = "lxml-4.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4"}, - {file = "lxml-4.9.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf"}, - {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3"}, - {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391"}, - {file = "lxml-4.9.1-cp36-cp36m-win32.whl", hash = "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e"}, - {file = "lxml-4.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7"}, - {file = "lxml-4.9.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca"}, - {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785"}, - {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785"}, - {file = "lxml-4.9.1-cp37-cp37m-win32.whl", hash = "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a"}, - {file = "lxml-4.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e"}, - {file = "lxml-4.9.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715"}, - {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036"}, - {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387"}, - {file = "lxml-4.9.1-cp38-cp38-win32.whl", hash = "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94"}, - {file = "lxml-4.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345"}, - {file = "lxml-4.9.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000"}, - {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25"}, - {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd"}, - {file = "lxml-4.9.1-cp39-cp39-win32.whl", hash = "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb"}, - {file = "lxml-4.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73"}, - {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c"}, - {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"}, - {file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"}, + {file = "lxml-4.9.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2"}, + {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892"}, + {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a"}, + {file = "lxml-4.9.2-cp27-cp27m-win32.whl", hash = "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de"}, + {file = "lxml-4.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3"}, + {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50"}, + {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975"}, + {file = "lxml-4.9.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c"}, + {file = "lxml-4.9.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a"}, + {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4"}, + {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4"}, + {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7"}, + {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184"}, + {file = "lxml-4.9.2-cp310-cp310-win32.whl", hash = "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda"}, + {file = "lxml-4.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab"}, + {file = "lxml-4.9.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9"}, + {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf"}, + {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380"}, + {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92"}, + {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1"}, + {file = "lxml-4.9.2-cp311-cp311-win32.whl", hash = "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33"}, + {file = "lxml-4.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd"}, + {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0"}, + {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e"}, + {file = "lxml-4.9.2-cp35-cp35m-win32.whl", hash = "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df"}, + {file = "lxml-4.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5"}, + {file = "lxml-4.9.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53"}, + {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7"}, + {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe"}, + {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c"}, + {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1"}, + {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e"}, + {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74"}, + {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38"}, + {file = "lxml-4.9.2-cp36-cp36m-win32.whl", hash = "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5"}, + {file = "lxml-4.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3"}, + {file = "lxml-4.9.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03"}, + {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941"}, + {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726"}, + {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b"}, + {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894"}, + {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45"}, + {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e"}, + {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b"}, + {file = "lxml-4.9.2-cp37-cp37m-win32.whl", hash = "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe"}, + {file = "lxml-4.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9"}, + {file = "lxml-4.9.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8"}, + {file = "lxml-4.9.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24"}, + {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889"}, + {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f"}, + {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03"}, + {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c"}, + {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f"}, + {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457"}, + {file = "lxml-4.9.2-cp38-cp38-win32.whl", hash = "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b"}, + {file = "lxml-4.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7"}, + {file = "lxml-4.9.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1"}, + {file = "lxml-4.9.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140"}, + {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4"}, + {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf"}, + {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947"}, + {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5"}, + {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5"}, + {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2"}, + {file = "lxml-4.9.2-cp39-cp39-win32.whl", hash = "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1"}, + {file = "lxml-4.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f"}, + {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c"}, + {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a"}, + {file = "lxml-4.9.2-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419"}, + {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05"}, + {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f"}, + {file = "lxml-4.9.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9"}, + {file = "lxml-4.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5"}, + {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746"}, + {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7"}, + {file = "lxml-4.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409"}, + {file = "lxml-4.9.2.tar.gz", hash = "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67"}, ] [package.extras] @@ -1173,26 +1249,26 @@ requests = "*" [[package]] name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.5" files = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] [[package]] name = "openpyxl" -version = "3.0.10" +version = "3.1.2" description = "A Python library to read/write Excel 2010 xlsx/xlsm files" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "openpyxl-3.0.10-py2.py3-none-any.whl", hash = "sha256:0ab6d25d01799f97a9464630abacbb34aafecdcaa0ef3cba6d6b3499867d0355"}, - {file = "openpyxl-3.0.10.tar.gz", hash = "sha256:e47805627aebcf860edb4edf7987b1309c1b3632f3750538ed962bbcc3bd7449"}, + {file = "openpyxl-3.1.2-py2.py3-none-any.whl", hash = "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5"}, + {file = "openpyxl-3.1.2.tar.gz", hash = "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184"}, ] [package.dependencies] @@ -1200,19 +1276,16 @@ et-xmlfile = "*" [[package]] name = "packaging" -version = "21.3" +version = "23.1" description = "Core utilities for Python packages" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] -[package.dependencies] -pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" - [[package]] name = "parso" version = "0.8.3" @@ -1231,14 +1304,14 @@ testing = ["docopt", "pytest (<6.0.0)"] [[package]] name = "pathspec" -version = "0.10.1" +version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, - {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, + {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, + {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, ] [[package]] @@ -1307,19 +1380,19 @@ twisted = ["twisted"] [[package]] name = "platformdirs" -version = "2.5.3" +version = "3.5.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, - {file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, + {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"}, + {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"}, ] [package.extras] -docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] -test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -1339,14 +1412,14 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "prometheus-client" -version = "0.15.0" +version = "0.16.0" description = "Python client for the Prometheus monitoring system." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "prometheus_client-0.15.0-py3-none-any.whl", hash = "sha256:db7c05cbd13a0f79975592d112320f2605a325969b270a94b71dcabc47b931d2"}, - {file = "prometheus_client-0.15.0.tar.gz", hash = "sha256:be26aa452490cfcf6da953f9436e95a9f2b4d578ca80094b4458930e5f584ab1"}, + {file = "prometheus_client-0.16.0-py3-none-any.whl", hash = "sha256:0836af6eb2c8f4fed712b2f279f6c0a8bbab29f9f4aa15276b91c7cb0d1616ab"}, + {file = "prometheus_client-0.16.0.tar.gz", hash = "sha256:a03e35b359f14dd1630898543e2120addfdeacd1a6069c1367ae90fd93ad3f48"}, ] [package.extras] @@ -1354,14 +1427,14 @@ twisted = ["twisted"] [[package]] name = "prompt-toolkit" -version = "3.0.32" +version = "3.0.38" description = "Library for building powerful interactive command lines in Python" category = "dev" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.32-py3-none-any.whl", hash = "sha256:24becda58d49ceac4dc26232eb179ef2b21f133fecda7eed6018d341766ed76e"}, - {file = "prompt_toolkit-3.0.32.tar.gz", hash = "sha256:e7f2129cba4ff3b3656bbdda0e74ee00d2f874a8bcdb9dd16f5fec7b3e173cae"}, + {file = "prompt_toolkit-3.0.38-py3-none-any.whl", hash = "sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f"}, + {file = "prompt_toolkit-3.0.38.tar.gz", hash = "sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b"}, ] [package.dependencies] @@ -1369,83 +1442,74 @@ wcwidth = "*" [[package]] name = "psycopg2-binary" -version = "2.9.5" +version = "2.9.6" description = "psycopg2 - Python-PostgreSQL Database Adapter" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:0775d6252ccb22b15da3b5d7adbbf8cfe284916b14b6dc0ff503a23edb01ee85"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3520d7af1ebc838cc6084a3281145d5cd5bdd43fdef139e6db5af01b92596cb7"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cbc554ba47ecca8cd3396ddaca85e1ecfe3e48dd57dc5e415e59551affe568e"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:5d28ecdf191db558d0c07d0f16524ee9d67896edf2b7990eea800abeb23ebd61"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05b3d479425e047c848b9782cd7aac9c6727ce23181eb9647baf64ffdfc3da41"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e491e6489a6cb1d079df8eaa15957c277fdedb102b6a68cfbf40c4994412fd0"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:9e32cedc389bcb76d9f24ea8a012b3cb8385ee362ea437e1d012ffaed106c17d"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_10_9_universal2.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:426c2ae999135d64e6a18849a7d1ad0e1bd007277e4a8f4752eaa40a96b550ff"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:af0516e1711995cb08dc19bbd05bec7dbdebf4185f68870595156718d237df3e"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e72c91bda9880f097c8aa3601a2c0de6c708763ba8128006151f496ca9065935"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:56b2957a145f816726b109ee3d4e6822c23f919a7d91af5a94593723ed667835"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:70831e03bd53702c941da1a1ad36c17d825a24fbb26857b40913d58df82ec18b"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dbc332beaf8492b5731229a881807cd7b91b50dbbbaf7fe2faf46942eda64a24"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:95076399ec3b27a8f7fa1cc9a83417b1c920d55cf7a97f718a94efbb96c7f503"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-win32.whl", hash = "sha256:3fc33295cfccad697a97a76dec3f1e94ad848b7b163c3228c1636977966b51e2"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-win_amd64.whl", hash = "sha256:02551647542f2bf89073d129c73c05a25c372fc0a49aa50e0de65c3c143d8bd0"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:63e318dbe52709ed10d516a356f22a635e07a2e34c68145484ed96a19b0c4c68"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e518a0911c50f60313cb9e74a169a65b5d293770db4770ebf004245f24b5c5"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:68d81a2fe184030aa0c5c11e518292e15d342a667184d91e30644c9d533e53e1"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:7ee3095d02d6f38bd7d9a5358fcc9ea78fcdb7176921528dd709cc63f40184f5"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:46512486be6fbceef51d7660dec017394ba3e170299d1dc30928cbedebbf103a"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b911dfb727e247340d36ae20c4b9259e4a64013ab9888ccb3cbba69b77fd9636"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:422e3d43b47ac20141bc84b3d342eead8d8099a62881a501e97d15f6addabfe9"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-win32.whl", hash = "sha256:b8104f709590fff72af801e916817560dbe1698028cd0afe5a52d75ceb1fce5f"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-win_amd64.whl", hash = "sha256:7b3751857da3e224f5629400736a7b11e940b5da5f95fa631d86219a1beaafec"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:043a9fd45a03858ff72364b4b75090679bd875ee44df9c0613dc862ca6b98460"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9ffdc51001136b699f9563b1c74cc1f8c07f66ef7219beb6417a4c8aaa896c28"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc85b3777068ed30aff8242be2813038a929f2084f69e43ef869daddae50f6ee"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:7d07f552d1e412f4b4e64ce386d4c777a41da3b33f7098b6219012ba534fb2c2"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a0adef094c49f242122bb145c3c8af442070dc0e4312db17e49058c1702606d4"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00475004e5ed3e3bf5e056d66e5dcdf41a0dc62efcd57997acd9135c40a08a50"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7d88db096fa19d94f433420eaaf9f3c45382da2dd014b93e4bf3215639047c16"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:902844f9c4fb19b17dfa84d9e2ca053d4a4ba265723d62ea5c9c26b38e0aa1e6"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-win32.whl", hash = "sha256:4e7904d1920c0c89105c0517dc7e3f5c20fb4e56ba9cdef13048db76947f1d79"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:a36a0e791805aa136e9cbd0ffa040d09adec8610453ee8a753f23481a0057af5"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9c38d3869238e9d3409239bc05bc27d6b7c99c2a460ea337d2814b35fb4fea1b"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6527c8efa5226a9e787507652dd5ba97b62d29b53c371a85cd13f957fe4d42"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59137cdb970249ae60be2a49774c6dfb015bd0403f05af1fe61862e9626642d"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:d4c7b3a31502184e856df1f7bbb2c3735a05a8ce0ade34c5277e1577738a5c91"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b9a794cef1d9c1772b94a72eec6da144c18e18041d294a9ab47669bc77a80c1d"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5e65c6ac0ae4bf5bef1667029f81010b6017795dcb817ba5c7b8a8d61fab76f"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:74eddec4537ab1f701a1647214734bc52cee2794df748f6ae5908e00771f180a"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:01ad49d68dd8c5362e4bfb4158f2896dc6e0c02e87b8a3770fc003459f1a4425"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-win32.whl", hash = "sha256:937880290775033a743f4836aa253087b85e62784b63fd099ee725d567a48aa1"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:484405b883630f3e74ed32041a87456c5e0e63a8e3429aa93e8714c366d62bd1"}, + {file = "psycopg2-binary-2.9.6.tar.gz", hash = "sha256:1f64dcfb8f6e0c014c7f55e51c9759f024f70ea572fbdef123f85318c297947c"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d26e0342183c762de3276cca7a530d574d4e25121ca7d6e4a98e4f05cb8e4df7"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c48d8f2db17f27d41fb0e2ecd703ea41984ee19362cbce52c097963b3a1b4365"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe9dc0a884a8848075e576c1de0290d85a533a9f6e9c4e564f19adf8f6e54a7"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a76e027f87753f9bd1ab5f7c9cb8c7628d1077ef927f5e2446477153a602f2c"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6460c7a99fc939b849431f1e73e013d54aa54293f30f1109019c56a0b2b2ec2f"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae102a98c547ee2288637af07393dd33f440c25e5cd79556b04e3fca13325e5f"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9972aad21f965599ed0106f65334230ce826e5ae69fda7cbd688d24fa922415e"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a40c00dbe17c0af5bdd55aafd6ff6679f94a9be9513a4c7e071baf3d7d22a70"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cacbdc5839bdff804dfebc058fe25684cae322987f7a38b0168bc1b2df703fb1"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7f0438fa20fb6c7e202863e0d5ab02c246d35efb1d164e052f2f3bfe2b152bd0"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-win32.whl", hash = "sha256:b6c8288bb8a84b47e07013bb4850f50538aa913d487579e1921724631d02ea1b"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:61b047a0537bbc3afae10f134dc6393823882eb263088c271331602b672e52e9"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:964b4dfb7c1c1965ac4c1978b0f755cc4bd698e8aa2b7667c575fb5f04ebe06b"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afe64e9b8ea66866a771996f6ff14447e8082ea26e675a295ad3bdbffdd72afb"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e2ee79e7cf29582ef770de7dab3d286431b01c3bb598f8e05e09601b890081"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa74c903a3c1f0d9b1c7e7b53ed2d929a4910e272add6700c38f365a6002820"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83456c2d4979e08ff56180a76429263ea254c3f6552cd14ada95cff1dec9bb8"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0645376d399bfd64da57148694d78e1f431b1e1ee1054872a5713125681cf1be"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e99e34c82309dd78959ba3c1590975b5d3c862d6f279f843d47d26ff89d7d7e1"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ea29fc3ad9d91162c52b578f211ff1c931d8a38e1f58e684c45aa470adf19e2"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4ac30da8b4f57187dbf449294d23b808f8f53cad6b1fc3623fa8a6c11d176dd0"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e78e6e2a00c223e164c417628572a90093c031ed724492c763721c2e0bc2a8df"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-win32.whl", hash = "sha256:1876843d8e31c89c399e31b97d4b9725a3575bb9c2af92038464231ec40f9edb"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b4b24f75d16a89cc6b4cdff0eb6a910a966ecd476d1e73f7ce5985ff1328e9a6"}, + {file = "psycopg2_binary-2.9.6-cp36-cp36m-win32.whl", hash = "sha256:498807b927ca2510baea1b05cc91d7da4718a0f53cb766c154c417a39f1820a0"}, + {file = "psycopg2_binary-2.9.6-cp36-cp36m-win_amd64.whl", hash = "sha256:0d236c2825fa656a2d98bbb0e52370a2e852e5a0ec45fc4f402977313329174d"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:34b9ccdf210cbbb1303c7c4db2905fa0319391bd5904d32689e6dd5c963d2ea8"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d2222e61f313c4848ff05353653bf5f5cf6ce34df540e4274516880d9c3763"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30637a20623e2a2eacc420059be11527f4458ef54352d870b8181a4c3020ae6b"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8122cfc7cae0da9a3077216528b8bb3629c43b25053284cc868744bfe71eb141"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38601cbbfe600362c43714482f43b7c110b20cb0f8172422c616b09b85a750c5"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c7e62ab8b332147a7593a385d4f368874d5fe4ad4e341770d4983442d89603e3"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2ab652e729ff4ad76d400df2624d223d6e265ef81bb8aa17fbd63607878ecbee"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c83a74b68270028dc8ee74d38ecfaf9c90eed23c8959fca95bd703d25b82c88e"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d4e6036decf4b72d6425d5b29bbd3e8f0ff1059cda7ac7b96d6ac5ed34ffbacd"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-win32.whl", hash = "sha256:a8c28fd40a4226b4a84bdf2d2b5b37d2c7bd49486b5adcc200e8c7ec991dfa7e"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-win_amd64.whl", hash = "sha256:51537e3d299be0db9137b321dfb6a5022caaab275775680e0c3d281feefaca6b"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4499e0a83b7b7edcb8dabecbd8501d0d3a5ef66457200f77bde3d210d5debb"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e13a5a2c01151f1208d5207e42f33ba86d561b7a89fca67c700b9486a06d0e2"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e0f754d27fddcfd74006455b6e04e6705d6c31a612ec69ddc040a5468e44b4e"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d57c3fd55d9058645d26ae37d76e61156a27722097229d32a9e73ed54819982a"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71f14375d6f73b62800530b581aed3ada394039877818b2d5f7fc77e3bb6894d"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:441cc2f8869a4f0f4bb408475e5ae0ee1f3b55b33f350406150277f7f35384fc"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65bee1e49fa6f9cf327ce0e01c4c10f39165ee76d35c846ade7cb0ec6683e303"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:af335bac6b666cc6aea16f11d486c3b794029d9df029967f9938a4bed59b6a19"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cfec476887aa231b8548ece2e06d28edc87c1397ebd83922299af2e051cf2827"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65c07febd1936d63bfde78948b76cd4c2a411572a44ac50719ead41947d0f26b"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-win32.whl", hash = "sha256:4dfb4be774c4436a4526d0c554af0cc2e02082c38303852a36f6456ece7b3503"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:02c6e3cf3439e213e4ee930308dc122d6fb4d4bea9aef4a12535fbd605d1a2fe"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e9182eb20f41417ea1dd8e8f7888c4d7c6e805f8a7c98c1081778a3da2bee3e4"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a6979cf527e2603d349a91060f428bcb135aea2be3201dff794813256c274f1"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8338a271cb71d8da40b023a35d9c1e919eba6cbd8fa20a54b748a332c355d896"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ed340d2b858d6e6fb5083f87c09996506af483227735de6964a6100b4e6a54"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f81e65376e52f03422e1fb475c9514185669943798ed019ac50410fb4c4df232"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb13af3c5dd3a9588000910178de17010ebcccd37b4f9794b00595e3a8ddad3"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4c727b597c6444a16e9119386b59388f8a424223302d0c06c676ec8b4bc1f963"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d67fbdaf177da06374473ef6f7ed8cc0a9dc640b01abfe9e8a2ccb1b1402c1f"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0892ef645c2fabb0c75ec32d79f4252542d0caec1d5d949630e7d242ca4681a3"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:02c0f3757a4300cf379eb49f543fb7ac527fb00144d39246ee40e1df684ab514"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-win32.whl", hash = "sha256:c3dba7dab16709a33a847e5cd756767271697041fbe3fe97c215b1fc1f5c9848"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:f6a88f384335bb27812293fdb11ac6aee2ca3f51d3c7820fe03de0a304ab6249"}, ] [[package]] @@ -1519,21 +1583,21 @@ files = [ [[package]] name = "pydocstyle" -version = "6.1.1" +version = "6.3.0" description = "Python docstring style checker" category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, - {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, + {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, + {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, ] [package.dependencies] -snowballstemmer = "*" +snowballstemmer = ">=2.2.0" [package.extras] -toml = ["toml"] +toml = ["tomli (>=1.2.3)"] [[package]] name = "pyexcel" @@ -1655,14 +1719,14 @@ files = [ [[package]] name = "pygments" -version = "2.13.0" +version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, - {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, + {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, + {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, ] [package.extras] @@ -1670,38 +1734,23 @@ plugins = ["importlib-metadata"] [[package]] name = "pyopenssl" -version = "23.0.0" +version = "23.1.1" description = "Python wrapper module around the OpenSSL library" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "pyOpenSSL-23.0.0-py3-none-any.whl", hash = "sha256:df5fc28af899e74e19fccb5510df423581047e10ab6f1f4ba1763ff5fde844c0"}, - {file = "pyOpenSSL-23.0.0.tar.gz", hash = "sha256:c1cc5f86bcacefc84dada7d31175cae1b1518d5f60d3d0bb595a67822a868a6f"}, + {file = "pyOpenSSL-23.1.1-py3-none-any.whl", hash = "sha256:9e0c526404a210df9d2b18cd33364beadb0dc858a739b885677bc65e105d4a4c"}, + {file = "pyOpenSSL-23.1.1.tar.gz", hash = "sha256:841498b9bec61623b1b6c47ebbc02367c07d60e0e195f19790817f10cc8db0b7"}, ] [package.dependencies] -cryptography = ">=38.0.0,<40" +cryptography = ">=38.0.0,<41" [package.extras] docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] test = ["flaky", "pretend", "pytest (>=3.0.1)"] -[[package]] -name = "pyparsing" -version = "3.0.9" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "dev" -optional = false -python-versions = ">=3.6.8" -files = [ - {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, - {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - [[package]] name = "pyreadline" version = "2.1" @@ -1884,48 +1933,48 @@ six = ">=1.5" [[package]] name = "python-redmine" -version = "2.3.0" +version = "2.4.0" description = "Library for communicating with a Redmine project management application" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.7, <4" files = [ - {file = "python-redmine-2.3.0.tar.gz", hash = "sha256:96889d1ae59b5830337e2c2fff1e2ce54103e52bbb632bd7c648f7d2d0274d25"}, - {file = "python_redmine-2.3.0-py2.py3-none-any.whl", hash = "sha256:502680473a3f9b7a001f788969c62081023afa83d896c0a54963aed3cf198b89"}, + {file = "python-redmine-2.4.0.tar.gz", hash = "sha256:29e1c479e6bedc4b193f84dda25121a1a0fcc30969c7f0f6e729c5638749e9d8"}, + {file = "python_redmine-2.4.0-py3-none-any.whl", hash = "sha256:c9b6ee4465516c1794fe8038b98ddc8bf9d8caa3c0564cf2bc6ea7d4637a2d3a"}, ] [package.dependencies] -requests = ">=2.23.0" +requests = ">=2.28.2" [[package]] name = "pytz" -version = "2022.6" +version = "2022.7.1" description = "World timezone definitions, modern and historical" category = "main" optional = false python-versions = "*" files = [ - {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, - {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, + {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, + {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, ] [[package]] name = "requests" -version = "2.28.1" +version = "2.30.0" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=3.7, <4" +python-versions = ">=3.7" files = [ - {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, - {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, + {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"}, + {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" +charset-normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" +urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] @@ -1953,22 +2002,23 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 [[package]] name = "sentry-sdk" -version = "1.14.0" +version = "1.22.1" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.14.0.tar.gz", hash = "sha256:273fe05adf052b40fd19f6d4b9a5556316807246bd817e5e3482930730726bb0"}, - {file = "sentry_sdk-1.14.0-py2.py3-none-any.whl", hash = "sha256:72c00322217d813cf493fe76590b23a757e063ff62fec59299f4af7201dd4448"}, + {file = "sentry-sdk-1.22.1.tar.gz", hash = "sha256:052dff5069c6f0d836ee014323576824a9b40836fc003fb12489a1f19c60a3c9"}, + {file = "sentry_sdk-1.22.1-py2.py3-none-any.whl", hash = "sha256:c6c6946f8c927adb00af1c5ab6921df38775b2199b9003816d5935a1310352d5"}, ] [package.dependencies] certifi = "*" -urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} +urllib3 = {version = ">=1.26.11,<2.0.0", markers = "python_version >= \"3.6\""} [package.extras] aiohttp = ["aiohttp (>=3.5)"] +arq = ["arq (>=0.23)"] beam = ["apache-beam (>=2.12)"] bottle = ["bottle (>=0.12.13)"] celery = ["celery (>=3)"] @@ -1977,7 +2027,9 @@ django = ["django (>=1.8)"] falcon = ["falcon (>=1.4)"] fastapi = ["fastapi (>=0.79.0)"] flask = ["blinker (>=1.1)", "flask (>=0.11)"] +grpcio = ["grpcio (>=1.21.1)"] httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] pure-eval = ["asttokens", "executing", "pure-eval"] pymongo = ["pymongo (>=3.1)"] @@ -1992,18 +2044,18 @@ tornado = ["tornado (>=5)"] [[package]] name = "setuptools" -version = "65.5.1" +version = "67.7.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-65.5.1-py3-none-any.whl", hash = "sha256:d0b9a8433464d5800cbe05094acf5c6d52a91bfac9b52bcfc4d41382be5d5d31"}, - {file = "setuptools-65.5.1.tar.gz", hash = "sha256:e197a19aa8ec9722928f2206f8de752def0e4c9fc6953527360d1c36d94ddb2f"}, + {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, + {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] @@ -2055,26 +2107,31 @@ files = [ [[package]] name = "sqlparse" -version = "0.4.3" +version = "0.4.4" description = "A non-validating SQL parser." category = "main" optional = false python-versions = ">=3.5" files = [ - {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, - {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, + {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, + {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, ] +[package.extras] +dev = ["build", "flake8"] +doc = ["sphinx"] +test = ["pytest", "pytest-cov"] + [[package]] name = "stack-data" -version = "0.6.0" +version = "0.6.2" description = "Extract data from python stack frames and tracebacks for informative displays" category = "dev" optional = false python-versions = "*" files = [ - {file = "stack_data-0.6.0-py3-none-any.whl", hash = "sha256:b92d206ef355a367d14316b786ab41cb99eb453a21f2cb216a4204625ff7bc07"}, - {file = "stack_data-0.6.0.tar.gz", hash = "sha256:8e515439f818efaa251036af72d89e4026e2b03993f3453c000b200fb4f2d6aa"}, + {file = "stack_data-0.6.2-py3-none-any.whl", hash = "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8"}, + {file = "stack_data-0.6.2.tar.gz", hash = "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815"}, ] [package.dependencies] @@ -2087,14 +2144,14 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] [[package]] name = "termcolor" -version = "2.1.0" +version = "2.3.0" description = "ANSI color formatting for output in terminal" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "termcolor-2.1.0-py3-none-any.whl", hash = "sha256:91dd04fdf661b89d7169cefd35f609b19ca931eb033687eaa647cef1ff177c49"}, - {file = "termcolor-2.1.0.tar.gz", hash = "sha256:b80df54667ce4f48c03fe35df194f052dc27a541ebbf2544e4d6b47b5d6949c4"}, + {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, + {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, ] [package.extras] @@ -2119,14 +2176,14 @@ test = ["django", "django (<2)", "mock", "pytest (>=3.6)", "pytest-cov", "pytest [[package]] name = "texttable" -version = "1.6.4" -description = "module for creating simple ASCII tables" +version = "1.6.7" +description = "module to create simple ASCII tables" category = "main" optional = false python-versions = "*" files = [ - {file = "texttable-1.6.4-py2.py3-none-any.whl", hash = "sha256:dd2b0eaebb2a9e167d1cefedab4700e5dcbdb076114eed30b58b97ed6b37d6f2"}, - {file = "texttable-1.6.4.tar.gz", hash = "sha256:42ee7b9e15f7b225747c3fa08f43c5d6c83bc899f80ff9bae9319334824076e9"}, + {file = "texttable-1.6.7-py2.py3-none-any.whl", hash = "sha256:b7b68139aa8a6339d2c320ca8b1dc42d13a7831a346b446cb9eb385f0c76310c"}, + {file = "texttable-1.6.7.tar.gz", hash = "sha256:290348fb67f7746931bcdfd55ac7584ecd4e5b0846ab164333f0794b121760f2"}, ] [[package]] @@ -2155,63 +2212,63 @@ files = [ [[package]] name = "tornado" -version = "6.2" +version = "6.3.1" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." category = "main" optional = false -python-versions = ">= 3.7" +python-versions = ">= 3.8" files = [ - {file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"}, - {file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"}, - {file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"}, - {file = "tornado-6.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"}, - {file = "tornado-6.2-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a2f5999215a3a06a4fc218026cd84c61b8b2b40ac5296a6db1f1451ef04c1e"}, - {file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"}, - {file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"}, - {file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"}, - {file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"}, - {file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"}, - {file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"}, + {file = "tornado-6.3.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:db181eb3df8738613ff0a26f49e1b394aade05034b01200a63e9662f347d4415"}, + {file = "tornado-6.3.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b4e7b956f9b5e6f9feb643ea04f07e7c6b49301e03e0023eedb01fa8cf52f579"}, + {file = "tornado-6.3.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9661aa8bc0e9d83d757cd95b6f6d1ece8ca9fd1ccdd34db2de381e25bf818233"}, + {file = "tornado-6.3.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81c17e0cc396908a5e25dc8e9c5e4936e6dfd544c9290be48bd054c79bcad51e"}, + {file = "tornado-6.3.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27a1cfa9997923f80bdd962b3aab048ac486ad8cfb2f237964f8ab7f7eb824b"}, + {file = "tornado-6.3.1-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d7117f3c7ba5d05813b17a1f04efc8e108a1b811ccfddd9134cc68553c414864"}, + {file = "tornado-6.3.1-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:ffdce65a281fd708da5a9def3bfb8f364766847fa7ed806821a69094c9629e8a"}, + {file = "tornado-6.3.1-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:90f569a35a8ec19bde53aa596952071f445da678ec8596af763b9b9ce07605e6"}, + {file = "tornado-6.3.1-cp38-abi3-win32.whl", hash = "sha256:3455133b9ff262fd0a75630af0a8ee13564f25fb4fd3d9ce239b8a7d3d027bf8"}, + {file = "tornado-6.3.1-cp38-abi3-win_amd64.whl", hash = "sha256:1285f0691143f7ab97150831455d4db17a267b59649f7bd9700282cba3d5e771"}, + {file = "tornado-6.3.1.tar.gz", hash = "sha256:5e2f49ad371595957c50e42dd7e5c14d64a6843a3cf27352b69c706d1b5918af"}, ] [[package]] name = "traitlets" -version = "5.5.0" -description = "" +version = "5.9.0" +description = "Traitlets Python configuration system" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "traitlets-5.5.0-py3-none-any.whl", hash = "sha256:1201b2c9f76097195989cdf7f65db9897593b0dfd69e4ac96016661bb6f0d30f"}, - {file = "traitlets-5.5.0.tar.gz", hash = "sha256:b122f9ff2f2f6c1709dab289a05555be011c87828e911c0cf4074b85cb780a79"}, + {file = "traitlets-5.9.0-py3-none-any.whl", hash = "sha256:9e6ec080259b9a5940c797d58b613b5e31441c2257b87c2e795c5228ae80d2d8"}, + {file = "traitlets-5.9.0.tar.gz", hash = "sha256:f6cde21a9c68cf756af02035f72d5a723bf607e862e7be33ece505abf4a3bad9"}, ] [package.extras] docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] -test = ["pre-commit", "pytest"] +test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] [[package]] name = "typing-extensions" -version = "4.4.0" +version = "4.5.0" description = "Backported and Experimental Type Hints for Python 3.7+" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, - {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, + {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, + {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, ] [[package]] name = "urllib3" -version = "1.26.12" +version = "1.26.15" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, - {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, + {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, + {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, ] [package.extras] @@ -2269,26 +2326,26 @@ files = [ [[package]] name = "wcwidth" -version = "0.2.5" +version = "0.2.6" description = "Measures the displayed width of unicode strings in a terminal" category = "dev" optional = false python-versions = "*" files = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, + {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, + {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, ] [[package]] name = "whitenoise" -version = "6.2.0" +version = "6.4.0" description = "Radically simplified static file serving for WSGI applications" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "whitenoise-6.2.0-py3-none-any.whl", hash = "sha256:8e9c600a5c18bd17655ef668ad55b5edf6c24ce9bdca5bf607649ca4b1e8e2c2"}, - {file = "whitenoise-6.2.0.tar.gz", hash = "sha256:8fa943c6d4cd9e27673b70c21a07b0aa120873901e099cd46cab40f7cc96d567"}, + {file = "whitenoise-6.4.0-py3-none-any.whl", hash = "sha256:599dc6ca57e48929dfeffb2e8e187879bfe2aed0d49ca419577005b7f2cc930b"}, + {file = "whitenoise-6.4.0.tar.gz", hash = "sha256:a02d6660ad161ff17e3042653c8e3f5ecbb2a2481a006bde125b9efb9a30113a"}, ] [package.extras] @@ -2307,21 +2364,21 @@ files = [ [[package]] name = "zipp" -version = "3.10.0" +version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, - {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "b95888749831a6e394548b5e999e940a9ae252d4109387f887d79e711e64ee86" +content-hash = "42d259e7740edddf75dde8e7b87584c8a83f62a1d75ac397bff1106cf7aad584" diff --git a/pyproject.toml b/pyproject.toml index d21c14813..b757bbf14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ include = ["CHANGELOG.md"] [tool.poetry.dependencies] python = "^3.8" python-dateutil = "^2.8.2" -django = "^3.2.18" +django = "^3.2.19" # might remove this once we find out how the jsonapi extras_require work django-cors-headers = "^3.13.0" django-filter = "^21.1" @@ -101,6 +101,7 @@ filterwarnings = [ "ignore:django.conf.urls.url().*:django.utils.deprecation.RemovedInDjango40Warning", "ignore:.*is deprecated in favour of new moneyed.l10n.format_money.*", "ignore:.*invalid escape sequence.*", + "ignore:pkg_resources is deprecated as an API:DeprecationWarning", ] [tool.coverage.run] From c464aa0fba07155c5dcbd72e3bb693434da9748c Mon Sep 17 00:00:00 2001 From: Wiktork Date: Mon, 8 May 2023 15:52:41 +0200 Subject: [PATCH 968/980] chore(subscription): update choices for currency --- .../0006_alter_package_price_currency.py | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 timed/subscription/migrations/0006_alter_package_price_currency.py diff --git a/timed/subscription/migrations/0006_alter_package_price_currency.py b/timed/subscription/migrations/0006_alter_package_price_currency.py new file mode 100644 index 000000000..b1b8357f4 --- /dev/null +++ b/timed/subscription/migrations/0006_alter_package_price_currency.py @@ -0,0 +1,334 @@ +# Generated by Django 3.2.19 on 2023-05-08 13:49 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("subscription", "0005_alter_package_price_currency"), + ] + + operations = [ + migrations.AlterField( + model_name="package", + name="price_currency", + field=djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghan Afghani"), + ("AFA", "Afghan Afghani (1927–2002)"), + ("ALL", "Albanian Lek"), + ("ALK", "Albanian Lek (1946–1965)"), + ("DZD", "Algerian Dinar"), + ("ADP", "Andorran Peseta"), + ("AOA", "Angolan Kwanza"), + ("AOK", "Angolan Kwanza (1977–1991)"), + ("AON", "Angolan New Kwanza (1990–2000)"), + ("AOR", "Angolan Readjusted Kwanza (1995–1999)"), + ("ARA", "Argentine Austral"), + ("ARS", "Argentine Peso"), + ("ARM", "Argentine Peso (1881–1970)"), + ("ARP", "Argentine Peso (1983–1985)"), + ("ARL", "Argentine Peso Ley (1970–1983)"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Florin"), + ("AUD", "Australian Dollar"), + ("ATS", "Austrian Schilling"), + ("AZN", "Azerbaijani Manat"), + ("AZM", "Azerbaijani Manat (1993–2006)"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("BDT", "Bangladeshi Taka"), + ("BBD", "Barbadian Dollar"), + ("BYN", "Belarusian Ruble"), + ("BYB", "Belarusian Ruble (1994–1999)"), + ("BYR", "Belarusian Ruble (2000–2016)"), + ("BEF", "Belgian Franc"), + ("BEC", "Belgian Franc (convertible)"), + ("BEL", "Belgian Franc (financial)"), + ("BZD", "Belize Dollar"), + ("BMD", "Bermudan Dollar"), + ("BTN", "Bhutanese Ngultrum"), + ("BOB", "Bolivian Boliviano"), + ("BOL", "Bolivian Boliviano (1863–1963)"), + ("BOV", "Bolivian Mvdol"), + ("BOP", "Bolivian Peso"), + ("BAM", "Bosnia-Herzegovina Convertible Mark"), + ("BAD", "Bosnia-Herzegovina Dinar (1992–1994)"), + ("BAN", "Bosnia-Herzegovina New Dinar (1994–1997)"), + ("BWP", "Botswanan Pula"), + ("BRC", "Brazilian Cruzado (1986–1989)"), + ("BRZ", "Brazilian Cruzeiro (1942–1967)"), + ("BRE", "Brazilian Cruzeiro (1990–1993)"), + ("BRR", "Brazilian Cruzeiro (1993–1994)"), + ("BRN", "Brazilian New Cruzado (1989–1990)"), + ("BRB", "Brazilian New Cruzeiro (1967–1986)"), + ("BRL", "Brazilian Real"), + ("GBP", "British Pound"), + ("BND", "Brunei Dollar"), + ("BGL", "Bulgarian Hard Lev"), + ("BGN", "Bulgarian Lev"), + ("BGO", "Bulgarian Lev (1879–1952)"), + ("BGM", "Bulgarian Socialist Lev"), + ("BUK", "Burmese Kyat"), + ("BIF", "Burundian Franc"), + ("XPF", "CFP Franc"), + ("KHR", "Cambodian Riel"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verdean Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("XAF", "Central African CFA Franc"), + ("CLE", "Chilean Escudo"), + ("CLP", "Chilean Peso"), + ("CLF", "Chilean Unit of Account (UF)"), + ("CNX", "Chinese People’s Bank Dollar"), + ("CNY", "Chinese Yuan"), + ("CNH", "Chinese Yuan (offshore)"), + ("COP", "Colombian Peso"), + ("COU", "Colombian Real Value Unit"), + ("KMF", "Comorian Franc"), + ("CDF", "Congolese Franc"), + ("CRC", "Costa Rican Colón"), + ("HRD", "Croatian Dinar"), + ("HRK", "Croatian Kuna"), + ("CUC", "Cuban Convertible Peso"), + ("CUP", "Cuban Peso"), + ("CYP", "Cypriot Pound"), + ("CZK", "Czech Koruna"), + ("CSK", "Czechoslovak Hard Koruna"), + ("DKK", "Danish Krone"), + ("DJF", "Djiboutian Franc"), + ("DOP", "Dominican Peso"), + ("NLG", "Dutch Guilder"), + ("XCD", "East Caribbean Dollar"), + ("DDM", "East German Mark"), + ("ECS", "Ecuadorian Sucre"), + ("ECV", "Ecuadorian Unit of Constant Value"), + ("EGP", "Egyptian Pound"), + ("GQE", "Equatorial Guinean Ekwele"), + ("ERN", "Eritrean Nakfa"), + ("EEK", "Estonian Kroon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBA", "European Composite Unit"), + ("XEU", "European Currency Unit"), + ("XBB", "European Monetary Unit"), + ("XBC", "European Unit of Account (XBC)"), + ("XBD", "European Unit of Account (XBD)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fijian Dollar"), + ("FIM", "Finnish Markka"), + ("FRF", "French Franc"), + ("XFO", "French Gold Franc"), + ("XFU", "French UIC-Franc"), + ("GMD", "Gambian Dalasi"), + ("GEK", "Georgian Kupon Larit"), + ("GEL", "Georgian Lari"), + ("DEM", "German Mark"), + ("GHS", "Ghanaian Cedi"), + ("GHC", "Ghanaian Cedi (1979–2007)"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("GRD", "Greek Drachma"), + ("GTQ", "Guatemalan Quetzal"), + ("GWP", "Guinea-Bissau Peso"), + ("GNF", "Guinean Franc"), + ("GNS", "Guinean Syli"), + ("GYD", "Guyanaese Dollar"), + ("HTG", "Haitian Gourde"), + ("HNL", "Honduran Lempira"), + ("HKD", "Hong Kong Dollar"), + ("HUF", "Hungarian Forint"), + ("IMP", "IMP"), + ("ISK", "Icelandic Króna"), + ("ISJ", "Icelandic Króna (1918–1981)"), + ("INR", "Indian Rupee"), + ("IDR", "Indonesian Rupiah"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IEP", "Irish Pound"), + ("ILS", "Israeli New Shekel"), + ("ILP", "Israeli Pound"), + ("ILR", "Israeli Shekel (1980–1985)"), + ("ITL", "Italian Lira"), + ("JMD", "Jamaican Dollar"), + ("JPY", "Japanese Yen"), + ("JOD", "Jordanian Dinar"), + ("KZT", "Kazakhstani Tenge"), + ("KES", "Kenyan Shilling"), + ("KWD", "Kuwaiti Dinar"), + ("KGS", "Kyrgystani Som"), + ("LAK", "Laotian Kip"), + ("LVL", "Latvian Lats"), + ("LVR", "Latvian Ruble"), + ("LBP", "Lebanese Pound"), + ("LSL", "Lesotho Loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("LTL", "Lithuanian Litas"), + ("LTT", "Lithuanian Talonas"), + ("LUL", "Luxembourg Financial Franc"), + ("LUC", "Luxembourgian Convertible Franc"), + ("LUF", "Luxembourgian Franc"), + ("MOP", "Macanese Pataca"), + ("MKD", "Macedonian Denar"), + ("MKN", "Macedonian Denar (1992–1993)"), + ("MGA", "Malagasy Ariary"), + ("MGF", "Malagasy Franc"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("MVR", "Maldivian Rufiyaa"), + ("MVP", "Maldivian Rupee (1947–1981)"), + ("MLF", "Malian Franc"), + ("MTL", "Maltese Lira"), + ("MTP", "Maltese Pound"), + ("MRU", "Mauritanian Ouguiya"), + ("MRO", "Mauritanian Ouguiya (1973–2017)"), + ("MUR", "Mauritian Rupee"), + ("MXV", "Mexican Investment Unit"), + ("MXN", "Mexican Peso"), + ("MXP", "Mexican Silver Peso (1861–1992)"), + ("MDC", "Moldovan Cupon"), + ("MDL", "Moldovan Leu"), + ("MCF", "Monegasque Franc"), + ("MNT", "Mongolian Tugrik"), + ("MAD", "Moroccan Dirham"), + ("MAF", "Moroccan Franc"), + ("MZE", "Mozambican Escudo"), + ("MZN", "Mozambican Metical"), + ("MZM", "Mozambican Metical (1980–2006)"), + ("MMK", "Myanmar Kyat"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillean Guilder"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("NIO", "Nicaraguan Córdoba"), + ("NIC", "Nicaraguan Córdoba (1988–1991)"), + ("NGN", "Nigerian Naira"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("OMR", "Omani Rial"), + ("PKR", "Pakistani Rupee"), + ("XPD", "Palladium"), + ("PAB", "Panamanian Balboa"), + ("PGK", "Papua New Guinean Kina"), + ("PYG", "Paraguayan Guarani"), + ("PEI", "Peruvian Inti"), + ("PEN", "Peruvian Sol"), + ("PES", "Peruvian Sol (1863–1965)"), + ("PHP", "Philippine Peso"), + ("XPT", "Platinum"), + ("PLN", "Polish Zloty"), + ("PLZ", "Polish Zloty (1950–1995)"), + ("PTE", "Portuguese Escudo"), + ("GWE", "Portuguese Guinea Escudo"), + ("QAR", "Qatari Riyal"), + ("XRE", "RINET Funds"), + ("RHD", "Rhodesian Dollar"), + ("RON", "Romanian Leu"), + ("ROL", "Romanian Leu (1952–2006)"), + ("RUB", "Russian Ruble"), + ("RUR", "Russian Ruble (1991–1998)"), + ("RWF", "Rwandan Franc"), + ("SVC", "Salvadoran Colón"), + ("WST", "Samoan Tala"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("CSD", "Serbian Dinar (2002–2006)"), + ("SCR", "Seychellois Rupee"), + ("SLL", "Sierra Leonean Leone (1964—2022)"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SKK", "Slovak Koruna"), + ("SIT", "Slovenian Tolar"), + ("SBD", "Solomon Islands Dollar"), + ("SOS", "Somali Shilling"), + ("ZAR", "South African Rand"), + ("ZAL", "South African Rand (financial)"), + ("KRH", "South Korean Hwan (1953–1962)"), + ("KRW", "South Korean Won"), + ("KRO", "South Korean Won (1945–1953)"), + ("SSP", "South Sudanese Pound"), + ("SUR", "Soviet Rouble"), + ("ESP", "Spanish Peseta"), + ("ESA", "Spanish Peseta (A account)"), + ("ESB", "Spanish Peseta (convertible account)"), + ("XDR", "Special Drawing Rights"), + ("LKR", "Sri Lankan Rupee"), + ("SHP", "St. Helena Pound"), + ("XSU", "Sucre"), + ("SDD", "Sudanese Dinar (1992–2007)"), + ("SDG", "Sudanese Pound"), + ("SDP", "Sudanese Pound (1957–1998)"), + ("SRD", "Surinamese Dollar"), + ("SRG", "Surinamese Guilder"), + ("SZL", "Swazi Lilangeni"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("STN", "São Tomé & Príncipe Dobra"), + ("STD", "São Tomé & Príncipe Dobra (1977–2017)"), + ("TVD", "TVD"), + ("TJR", "Tajikistani Ruble"), + ("TJS", "Tajikistani Somoni"), + ("TZS", "Tanzanian Shilling"), + ("XTS", "Testing Currency Code"), + ("THB", "Thai Baht"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TPE", "Timorese Escudo"), + ("TOP", "Tongan Paʻanga"), + ("TTD", "Trinidad & Tobago Dollar"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TRL", "Turkish Lira (1922–2005)"), + ("TMT", "Turkmenistani Manat"), + ("TMM", "Turkmenistani Manat (1993–2009)"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("USS", "US Dollar (Same day)"), + ("UGX", "Ugandan Shilling"), + ("UGS", "Ugandan Shilling (1966–1987)"), + ("UAH", "Ukrainian Hryvnia"), + ("UAK", "Ukrainian Karbovanets"), + ("AED", "United Arab Emirates Dirham"), + ("UYW", "Uruguayan Nominal Wage Index Unit"), + ("UYU", "Uruguayan Peso"), + ("UYP", "Uruguayan Peso (1975–1993)"), + ("UYI", "Uruguayan Peso (Indexed Units)"), + ("UZS", "Uzbekistani Som"), + ("VUV", "Vanuatu Vatu"), + ("VES", "Venezuelan Bolívar"), + ("VEB", "Venezuelan Bolívar (1871–2008)"), + ("VEF", "Venezuelan Bolívar (2008–2018)"), + ("VND", "Vietnamese Dong"), + ("VNN", "Vietnamese Dong (1978–1985)"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("XOF", "West African CFA Franc"), + ("YDD", "Yemeni Dinar"), + ("YER", "Yemeni Rial"), + ("YUN", "Yugoslavian Convertible Dinar (1990–1992)"), + ("YUD", "Yugoslavian Hard Dinar (1966–1990)"), + ("YUM", "Yugoslavian New Dinar (1994–2002)"), + ("YUR", "Yugoslavian Reformed Dinar (1992–1993)"), + ("ZWN", "ZWN"), + ("ZRN", "Zairean New Zaire (1993–1998)"), + ("ZRZ", "Zairean Zaire (1971–1993)"), + ("ZMW", "Zambian Kwacha"), + ("ZMK", "Zambian Kwacha (1968–2012)"), + ("ZWD", "Zimbabwean Dollar (1980–2008)"), + ("ZWR", "Zimbabwean Dollar (2008)"), + ("ZWL", "Zimbabwean Dollar (2009)"), + ], + default="CHF", + editable=False, + max_length=3, + ), + ), + ] From fe54dde4a97a987422cc6b46b80b280cc0f0d588 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 9 May 2023 10:12:53 +0200 Subject: [PATCH 969/980] chore(deps): pin openpyxl to version 3.0.10 newer versions of openpyxl use sets for cell ranges, which is not supported by pyxcel-xlsx (which uses openpyxl) version can be updated once the cell ranges use lists again or once pyexcel-xlsx adapts the use of sets for cell ranges https://foss.heptapod.net/openpyxl/openpyxl/-/commit/15957c567b9519104dac5da42ad03c261cd0106b https://foss.heptapod.net/openpyxl/openpyxl/-/commit/43039ea101675dbe3b81cd32b4f33112345ace5a --- poetry.lock | 20 ++++++++++---------- pyproject.toml | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/poetry.lock b/poetry.lock index f2cf499dc..80625fa8a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -754,14 +754,14 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "18.6.2" +version = "18.7.0" description = "Faker is a Python package that generates fake data for you." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "Faker-18.6.2-py3-none-any.whl", hash = "sha256:6385386ba8d5aa255bec72f5392c2b795fcec8bebf975a9953488948d54bce35"}, - {file = "Faker-18.6.2.tar.gz", hash = "sha256:ef61bbf266d30819e83bab4a6c74a0f5979ce4d19d4c9305719dd26cb7d8d51c"}, + {file = "Faker-18.7.0-py3-none-any.whl", hash = "sha256:38dbc3b80e655d7301e190426ab30f04b6b7f6ca4764c5dd02772ffde0fa6dcd"}, + {file = "Faker-18.7.0.tar.gz", hash = "sha256:f02c6d3fdb5bc781f80b440cf2bdec336ed47ecfb8d620b20c3d4188ed051831"}, ] [package.dependencies] @@ -1261,14 +1261,14 @@ files = [ [[package]] name = "openpyxl" -version = "3.1.2" +version = "3.0.10" description = "A Python library to read/write Excel 2010 xlsx/xlsm files" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "openpyxl-3.1.2-py2.py3-none-any.whl", hash = "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5"}, - {file = "openpyxl-3.1.2.tar.gz", hash = "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184"}, + {file = "openpyxl-3.0.10-py2.py3-none-any.whl", hash = "sha256:0ab6d25d01799f97a9464630abacbb34aafecdcaa0ef3cba6d6b3499867d0355"}, + {file = "openpyxl-3.0.10.tar.gz", hash = "sha256:e47805627aebcf860edb4edf7987b1309c1b3632f3750538ed962bbcc3bd7449"}, ] [package.dependencies] @@ -2002,14 +2002,14 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 [[package]] name = "sentry-sdk" -version = "1.22.1" +version = "1.22.2" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.22.1.tar.gz", hash = "sha256:052dff5069c6f0d836ee014323576824a9b40836fc003fb12489a1f19c60a3c9"}, - {file = "sentry_sdk-1.22.1-py2.py3-none-any.whl", hash = "sha256:c6c6946f8c927adb00af1c5ab6921df38775b2199b9003816d5935a1310352d5"}, + {file = "sentry-sdk-1.22.2.tar.gz", hash = "sha256:5932c092c6e6035584eb74d77064e4bce3b7935dfc4a331349719a40db265840"}, + {file = "sentry_sdk-1.22.2-py2.py3-none-any.whl", hash = "sha256:cf89a5063ef84278d186aceaed6fb595bfe67d099298e537634a323664265669"}, ] [package.dependencies] @@ -2381,4 +2381,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "42d259e7740edddf75dde8e7b87584c8a83f62a1d75ac397bff1106cf7aad584" +content-hash = "81c83402adc3a82cd39f8212b2e93eff96c877e7381a220d9cf020104bca5746" diff --git a/pyproject.toml b/pyproject.toml index b757bbf14..6ac8ea9e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ python-redmine = "^2.3.0" sentry-sdk = "^1.9.5" whitenoise = "^6.2.0" django-hurricane = "^1.3.4" +openpyxl = "3.0.10" # TODO: dependency of `pyexcel-xlsx` Remove as soon as https://github.com/pyexcel/pyexcel-xlsx/issues/52 is resolved. [tool.poetry.dev-dependencies] black = "22.3.0" From b269b06f1db11fd8fee17594edf419b76e54df69 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 9 May 2023 10:39:01 +0200 Subject: [PATCH 970/980] chore(release): v3.0.5 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8311068ae..7b341ef76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v3.0.5 + +### Fix +* **tracking:** Fix updating own rejected reports and rejecting own reports ([`6a5d0ed`](https://github.com/adfinis/timed-backend/commit/6a5d0eda470939c59ad9ea869d4296e0115dd33e)) + # v3.0.4 ### Fix diff --git a/pyproject.toml b/pyproject.toml index 6ac8ea9e8..31a050c72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "3.0.4" +version = "3.0.5" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From b5c509bc196137b28e84b814d4dba0a60b117747 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 24 May 2023 16:42:22 +0200 Subject: [PATCH 971/980] chore(redmine): add redmine issue id to log --- timed/redmine/management/commands/import_project_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timed/redmine/management/commands/import_project_data.py b/timed/redmine/management/commands/import_project_data.py index 98735c95b..9da4b245a 100644 --- a/timed/redmine/management/commands/import_project_data.py +++ b/timed/redmine/management/commands/import_project_data.py @@ -67,6 +67,6 @@ def handle(self, *args, **options): timed_project.save() self.stdout.write( self.style.SUCCESS( - f"Updating project {timed_project.name} with amount offered {timed_project.amount_offered} and amount invoiced {timed_project.amount_invoiced}" + f"Updating project {timed_project.name} #{redmine_project.id} with amount offered {timed_project.amount_offered} and amount invoiced {timed_project.amount_invoiced}" ) ) From 25d16a6fba3433163083e2d37c3335aa6e2b52af Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 25 May 2023 11:41:04 +0200 Subject: [PATCH 972/980] chore(release): v3.0.6 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b341ef76..5d6c37199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v3.0.6 + +### Feature +* **redmine:** Add redmine issue id to log ([`b5c509b`](https://github.com/adfinis/timed-backend/commit/b5c509bc196137b28e84b814d4dba0a60b117747)) + # v3.0.5 ### Fix diff --git a/pyproject.toml b/pyproject.toml index 31a050c72..79edd019f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "3.0.5" +version = "3.0.6" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From 9cd1aeeafefc71d7b3a74124ebfd8857d67bb633 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Tue, 30 May 2023 13:21:10 +0200 Subject: [PATCH 973/980] refactor(redmine): remove spent hours from redmine bot Remove spent hours from update_project_expenditure bot as redmine report already does it. --- .../management/commands/update_project_expenditure.py | 11 +---------- .../redmine/tests/test_update_project_expenditure.py | 8 +++----- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/timed/redmine/management/commands/update_project_expenditure.py b/timed/redmine/management/commands/update_project_expenditure.py index 4076f870c..5b5730e88 100644 --- a/timed/redmine/management/commands/update_project_expenditure.py +++ b/timed/redmine/management/commands/update_project_expenditure.py @@ -44,11 +44,6 @@ def handle(self, *args, **options): if project.estimated_time else 0.0 ) - total_spent_hours = ( - project.total_hours.total_seconds() / 3600 - if project.total_hours - else 0.0 - ) try: issue = redmine.issue.get(project.redmine_project.issue_id) except redminelib.exceptions.BaseRedmineError as e: @@ -69,10 +64,6 @@ def handle(self, *args, **options): # fields not active in Redmine projects settings won't be saved issue.custom_fields = [ - { - "id": settings.REDMINE_SPENTHOURS_FIELD, - "value": total_spent_hours, - }, { "id": settings.REDMINE_AMOUNT_OFFERED_FIELD, "value": amount_offered, @@ -95,6 +86,6 @@ def handle(self, *args, **options): self.stdout.write( self.style.SUCCESS( - f"Updating Redmine issue {project.redmine_project.issue_id} with total spent hours {total_spent_hours}, estimated time {estimated_hours}, amount offered {amount_offered}, amount invoiced {amount_invoiced}" + f"Updating Redmine issue {project.redmine_project.issue_id} with estimated time {estimated_hours}, amount offered {amount_offered}, amount invoiced {amount_invoiced}" ) ) diff --git a/timed/redmine/tests/test_update_project_expenditure.py b/timed/redmine/tests/test_update_project_expenditure.py index aeebe6ec3..01c4b6afc 100644 --- a/timed/redmine/tests/test_update_project_expenditure.py +++ b/timed/redmine/tests/test_update_project_expenditure.py @@ -18,7 +18,7 @@ def test_update_project_expenditure( redmine_class = mocker.patch("redminelib.Redmine") redmine_class.return_value = redmine_instance - report = report_factory(duration=datetime.timedelta(hours=4)) + report = report_factory() project = report.task.project project.estimated_time = datetime.timedelta(hours=10) project.amount_offered = amount_offered @@ -33,14 +33,12 @@ def test_update_project_expenditure( if not pretend: redmine_instance.issue.get.assert_called_once_with(1000) assert issue.estimated_hours == project.estimated_time.total_seconds() / 3600 - assert issue.custom_fields[0]["value"] == report.duration.total_seconds() / 3600 - assert issue.custom_fields[1]["value"] == offered - assert issue.custom_fields[2]["value"] == project.amount_invoiced.amount + assert issue.custom_fields[0]["value"] == offered + assert issue.custom_fields[1]["value"] == project.amount_invoiced.amount issue.save.assert_called_once_with() else: out, _ = capsys.readouterr() assert "Redmine issue 1000" in out - assert f"total spent hours {report.duration.total_seconds() / 3600}" in out assert f"amount offered {offered}" in out assert f"amount invoiced {project.amount_invoiced.amount}" in out From 5f613d1e83f007270228c109846fb6a525eced71 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 14 Jun 2023 10:57:15 +0200 Subject: [PATCH 974/980] fix(redmine): convert Decimal objects to floats Decimal objects are not JSON serializable, therefore we have to convert them to float. --- .../management/commands/update_project_expenditure.py | 8 ++++---- timed/redmine/tests/test_update_project_expenditure.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/timed/redmine/management/commands/update_project_expenditure.py b/timed/redmine/management/commands/update_project_expenditure.py index 5b5730e88..11d2d95e2 100644 --- a/timed/redmine/management/commands/update_project_expenditure.py +++ b/timed/redmine/management/commands/update_project_expenditure.py @@ -56,11 +56,11 @@ def handle(self, *args, **options): issue.estimated_hours = estimated_hours amount_offered = ( - project.amount_offered and project.amount_offered.amount - ) or "0.00" + project.amount_offered and float(project.amount_offered.amount) + ) or 0.0 amount_invoiced = ( - project.amount_invoiced and project.amount_invoiced.amount - ) or "0.00" + project.amount_invoiced and float(project.amount_invoiced.amount) + ) or 0.0 # fields not active in Redmine projects settings won't be saved issue.custom_fields = [ diff --git a/timed/redmine/tests/test_update_project_expenditure.py b/timed/redmine/tests/test_update_project_expenditure.py index 01c4b6afc..23a166d78 100644 --- a/timed/redmine/tests/test_update_project_expenditure.py +++ b/timed/redmine/tests/test_update_project_expenditure.py @@ -28,7 +28,7 @@ def test_update_project_expenditure( call_command("update_project_expenditure", pretend=pretend) - offered = (project.amount_offered and project.amount_offered.amount) or "0.00" + offered = (project.amount_offered and project.amount_offered.amount) or 0.0 if not pretend: redmine_instance.issue.get.assert_called_once_with(1000) From 4d2a6365581c2bd66eb21cb58a3b182249d564c9 Mon Sep 17 00:00:00 2001 From: Wiktor Kepczynski <55092761+trowik@users.noreply.github.com> Date: Wed, 14 Jun 2023 15:31:56 +0200 Subject: [PATCH 975/980] chore(deps): update dependencies (#973) --- poetry.lock | 218 +++++++++++----------------------------------------- 1 file changed, 47 insertions(+), 171 deletions(-) diff --git a/poetry.lock b/poetry.lock index 80625fa8a..1054696b8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.0 and should not be changed by hand. [[package]] name = "appnope" version = "0.1.3" description = "Disable App Nap on macOS >= 10.9" -category = "dev" optional = false python-versions = "*" files = [ @@ -16,7 +15,6 @@ files = [ name = "asgiref" version = "3.4.1" description = "ASGI specs, helper code, and adapters" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -31,7 +29,6 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] name = "asttokens" version = "2.2.1" description = "Annotate AST trees with source code positions" -category = "dev" optional = false python-versions = "*" files = [ @@ -49,7 +46,6 @@ test = ["astroid", "pytest"] name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -68,7 +64,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "babel" version = "2.12.1" description = "Internationalization utilities" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -83,7 +78,6 @@ pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} name = "backcall" version = "0.2.0" description = "Specifications for callback functions passed in to an API" -category = "dev" optional = false python-versions = "*" files = [ @@ -95,7 +89,6 @@ files = [ name = "black" version = "22.3.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.6.2" files = [ @@ -142,7 +135,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -154,7 +146,6 @@ files = [ name = "cffi" version = "1.15.1" description = "Foreign Function Interface for Python calling C code." -category = "main" optional = false python-versions = "*" files = [ @@ -231,7 +222,6 @@ pycparser = "*" name = "chardet" version = "5.1.0" description = "Universal encoding detector for Python 3" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -243,7 +233,6 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -328,7 +317,6 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -343,7 +331,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -355,7 +342,6 @@ files = [ name = "coverage" version = "6.4.1" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -410,31 +396,30 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "40.0.2" +version = "41.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:8f79b5ff5ad9d3218afb1e7e20ea74da5f76943ee5edb7f76e56ec5161ec782b"}, - {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05dc219433b14046c476f6f09d7636b92a1c3e5808b9a6536adf4932b3b2c440"}, - {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4df2af28d7bedc84fe45bd49bc35d710aede676e2a4cb7fc6d103a2adc8afe4d"}, - {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dcca15d3a19a66e63662dc8d30f8036b07be851a8680eda92d079868f106288"}, - {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a04386fb7bc85fab9cd51b6308633a3c271e3d0d3eae917eebab2fac6219b6d2"}, - {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:adc0d980fd2760c9e5de537c28935cc32b9353baaf28e0814df417619c6c8c3b"}, - {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d5a1bd0e9e2031465761dfa920c16b0065ad77321d8a8c1f5ee331021fda65e9"}, - {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a95f4802d49faa6a674242e25bfeea6fc2acd915b5e5e29ac90a32b1139cae1c"}, - {file = "cryptography-40.0.2-cp36-abi3-win32.whl", hash = "sha256:aecbb1592b0188e030cb01f82d12556cf72e218280f621deed7d806afd2113f9"}, - {file = "cryptography-40.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:b12794f01d4cacfbd3177b9042198f3af1c856eedd0a98f10f141385c809a14b"}, - {file = "cryptography-40.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:142bae539ef28a1c76794cca7f49729e7c54423f615cfd9b0b1fa90ebe53244b"}, - {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:956ba8701b4ffe91ba59665ed170a2ebbdc6fc0e40de5f6059195d9f2b33ca0e"}, - {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f01c9863da784558165f5d4d916093737a75203a5c5286fde60e503e4276c7a"}, - {file = "cryptography-40.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3daf9b114213f8ba460b829a02896789751626a2a4e7a43a28ee77c04b5e4958"}, - {file = "cryptography-40.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48f388d0d153350f378c7f7b41497a54ff1513c816bcbbcafe5b829e59b9ce5b"}, - {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c0764e72b36a3dc065c155e5b22f93df465da9c39af65516fe04ed3c68c92636"}, - {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cbaba590180cba88cb99a5f76f90808a624f18b169b90a4abb40c1fd8c19420e"}, - {file = "cryptography-40.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7a38250f433cd41df7fcb763caa3ee9362777fdb4dc642b9a349721d2bf47404"}, - {file = "cryptography-40.0.2.tar.gz", hash = "sha256:c33c0d32b8594fa647d2e01dbccc303478e16fdd7cf98652d5b3ed11aa5e5c99"}, + {file = "cryptography-41.0.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:f73bff05db2a3e5974a6fd248af2566134d8981fd7ab012e5dd4ddb1d9a70699"}, + {file = "cryptography-41.0.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1a5472d40c8f8e91ff7a3d8ac6dfa363d8e3138b961529c996f3e2df0c7a411a"}, + {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fa01527046ca5facdf973eef2535a27fec4cb651e4daec4d043ef63f6ecd4ca"}, + {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b46e37db3cc267b4dea1f56da7346c9727e1209aa98487179ee8ebed09d21e43"}, + {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d198820aba55660b4d74f7b5fd1f17db3aa5eb3e6893b0a41b75e84e4f9e0e4b"}, + {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:948224d76c4b6457349d47c0c98657557f429b4e93057cf5a2f71d603e2fc3a3"}, + {file = "cryptography-41.0.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:059e348f9a3c1950937e1b5d7ba1f8e968508ab181e75fc32b879452f08356db"}, + {file = "cryptography-41.0.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b4ceb5324b998ce2003bc17d519080b4ec8d5b7b70794cbd2836101406a9be31"}, + {file = "cryptography-41.0.1-cp37-abi3-win32.whl", hash = "sha256:8f4ab7021127a9b4323537300a2acfb450124b2def3756f64dc3a3d2160ee4b5"}, + {file = "cryptography-41.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:1fee5aacc7367487b4e22484d3c7e547992ed726d14864ee33c0176ae43b0d7c"}, + {file = "cryptography-41.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9a6c7a3c87d595608a39980ebaa04d5a37f94024c9f24eb7d10262b92f739ddb"}, + {file = "cryptography-41.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5d092fdfedaec4cbbffbf98cddc915ba145313a6fdaab83c6e67f4e6c218e6f3"}, + {file = "cryptography-41.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a8e6c2de6fbbcc5e14fd27fb24414507cb3333198ea9ab1258d916f00bc3039"}, + {file = "cryptography-41.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cb33ccf15e89f7ed89b235cff9d49e2e62c6c981a6061c9c8bb47ed7951190bc"}, + {file = "cryptography-41.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f0ff6e18d13a3de56f609dd1fd11470918f770c6bd5d00d632076c727d35485"}, + {file = "cryptography-41.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7bfc55a5eae8b86a287747053140ba221afc65eb06207bedf6e019b8934b477c"}, + {file = "cryptography-41.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eb8163f5e549a22888c18b0d53d6bb62a20510060a22fd5a995ec8a05268df8a"}, + {file = "cryptography-41.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8dde71c4169ec5ccc1087bb7521d54251c016f126f922ab2dfe6649170a3b8c5"}, + {file = "cryptography-41.0.1.tar.gz", hash = "sha256:d34579085401d3f49762d2f7d6634d6b6c2ae1242202e860f4d26b046e3a1006"}, ] [package.dependencies] @@ -443,18 +428,17 @@ cffi = ">=1.12" [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "check-manifest", "mypy", "ruff"] -sdist = ["setuptools-rust (>=0.11.4)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] -tox = ["tox"] [[package]] name = "decorator" version = "5.1.1" description = "Decorators for Humans" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -466,7 +450,6 @@ files = [ name = "django" version = "3.2.19" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -487,7 +470,6 @@ bcrypt = ["bcrypt"] name = "django-cors-headers" version = "3.14.0" description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -502,7 +484,6 @@ Django = ">=3.2" name = "django-environ" version = "0.8.1" description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." -category = "main" optional = false python-versions = ">=3.4,<4" files = [ @@ -511,15 +492,14 @@ files = [ ] [package.extras] -develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] -docs = ["furo (>=2021.8.17b43,<2021.9.0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] +develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] +docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] [[package]] name = "django-excel" version = "0.0.10" description = "A django middleware that provides one application programminginterface to read and write data in different excel file formats" -category = "main" optional = false python-versions = "*" files = [ @@ -541,7 +521,6 @@ xlsx = ["pyexcel-xlsx (>=0.4.0)"] name = "django-extensions" version = "3.2.1" description = "Extensions for Django" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -556,7 +535,6 @@ Django = ">=3.2" name = "django-filter" version = "21.1" description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -571,7 +549,6 @@ Django = ">=2.2" name = "django-hurricane" version = "1.3.4" description = "Hurricane is an initiative to fit Django perfectly with Kubernetes." -category = "main" optional = false python-versions = "~=3.8" files = [ @@ -594,7 +571,6 @@ pycharm = ["pydevd-pycharm (>=213.5605.23,<213.5606.0)"] name = "django-money" version = "2.1.1" description = "Adds support for using money and currency fields in django models and forms. Uses py-moneyed as the money implementation." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -615,7 +591,6 @@ test = ["mixer", "pytest (>=3.1.0)", "pytest-cov", "pytest-django", "pytest-pyth name = "django-multiselectfield" version = "0.1.12" description = "Django multiple select field" -category = "main" optional = false python-versions = "*" files = [ @@ -630,7 +605,6 @@ django = ">=1.4" name = "django-nested-inline" version = "0.4.6" description = "Recursive nesting of inline forms for Django Admin" -category = "main" optional = false python-versions = "*" files = [ @@ -642,7 +616,6 @@ files = [ name = "django-prometheus" version = "2.3.1" description = "Django middlewares to monitor your application with Prometheus.io." -category = "main" optional = false python-versions = "*" files = [ @@ -657,7 +630,6 @@ prometheus-client = ">=0.7" name = "djangorestframework" version = "3.13.1" description = "Web APIs for Django, made easy." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -673,7 +645,6 @@ pytz = "*" name = "djangorestframework-jsonapi" version = "5.0.0" description = "A Django REST framework API adapter for the JSON:API spec." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -695,7 +666,6 @@ openapi = ["pyyaml (>=5.4)", "uritemplate (>=3.0.1)"] name = "et-xmlfile" version = "1.1.0" description = "An implementation of lxml.xmlfile for the standard library" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -707,7 +677,6 @@ files = [ name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -722,7 +691,6 @@ test = ["pytest (>=6)"] name = "executing" version = "1.2.0" description = "Get the currently executing AST node of a frame, and other information" -category = "dev" optional = false python-versions = "*" files = [ @@ -737,7 +705,6 @@ tests = ["asttokens", "littleutils", "pytest", "rich"] name = "factory-boy" version = "3.2.1" description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -756,7 +723,6 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] name = "faker" version = "18.7.0" description = "Faker is a Python package that generates fake data for you." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -771,7 +737,6 @@ python-dateutil = ">=2.4" name = "fancycompleter" version = "0.9.1" description = "colorful TAB completion for Python prompt" -category = "dev" optional = false python-versions = "*" files = [ @@ -787,7 +752,6 @@ pyrepl = ">=0.8.2" name = "fastdiff" version = "0.3.0" description = "A fast native implementation of diff algorithm with a pure python fallback" -category = "dev" optional = false python-versions = "*" files = [ @@ -803,7 +767,6 @@ wasmer-compiler-cranelift = ">=1.0.0" name = "flake8" version = "4.0.1" description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -820,7 +783,6 @@ pyflakes = ">=2.4.0,<2.5.0" name = "flake8-blind-except" version = "0.2.1" description = "A flake8 extension that checks for blind except: statements" -category = "dev" optional = false python-versions = "*" files = [ @@ -831,7 +793,6 @@ files = [ name = "flake8-debugger" version = "4.1.2" description = "ipdb/pdb statement checker plugin for flake8" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -847,7 +808,6 @@ pycodestyle = "*" name = "flake8-deprecated" version = "1.3" description = "Warns about deprecated method calls." -category = "dev" optional = false python-versions = "*" files = [ @@ -862,7 +822,6 @@ flake8 = ">=3.0.0" name = "flake8-docstrings" version = "1.6.0" description = "Extension for flake8 which uses pydocstyle to check docstrings" -category = "dev" optional = false python-versions = "*" files = [ @@ -878,7 +837,6 @@ pydocstyle = ">=2.1" name = "flake8-isort" version = "4.1.1" description = "flake8 plugin that integrates isort ." -category = "dev" optional = false python-versions = "*" files = [ @@ -898,7 +856,6 @@ test = ["pytest-cov"] name = "flake8-string-format" version = "0.3.0" description = "string format checker, plugin for flake8" -category = "dev" optional = false python-versions = "*" files = [ @@ -913,7 +870,6 @@ flake8 = "*" name = "freezegun" version = "1.2.2" description = "Let your Python tests travel through time" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -928,7 +884,6 @@ python-dateutil = ">=2.7" name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -940,7 +895,6 @@ files = [ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -960,7 +914,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "inflection" version = "0.5.1" description = "A port of Ruby on Rails inflector to Python" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -972,7 +925,6 @@ files = [ name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -984,7 +936,6 @@ files = [ name = "ipdb" version = "0.13.9" description = "IPython-enabled pdb" -category = "dev" optional = false python-versions = ">=2.7" files = [ @@ -1001,7 +952,6 @@ toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} name = "ipython" version = "8.12.2" description = "IPython: Productive Interactive Computing" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1041,7 +991,6 @@ test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pa name = "isort" version = "5.10.1" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.6.1,<4.0" files = [ @@ -1059,7 +1008,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] name = "jedi" version = "0.18.2" description = "An autocompletion tool for Python that can be used for text editors." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1079,7 +1027,6 @@ testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] name = "josepy" version = "1.13.0" description = "JOSE protocol implementation in Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1101,7 +1048,6 @@ tests = ["coverage (>=4.0)", "flake8 (<4)", "isort", "mypy", "pytest (>=2.8.0)", name = "lml" version = "0.1.0" description = "Load me later. A lazy plugin management system." -category = "main" optional = false python-versions = "*" files = [ @@ -1113,7 +1059,6 @@ files = [ name = "lxml" version = "4.9.2" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" files = [ @@ -1206,7 +1151,6 @@ source = ["Cython (>=0.29.7)"] name = "matplotlib-inline" version = "0.1.6" description = "Inline Matplotlib backend for Jupyter" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1221,7 +1165,6 @@ traitlets = "*" name = "mccabe" version = "0.6.1" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = "*" files = [ @@ -1233,7 +1176,6 @@ files = [ name = "mozilla-django-oidc" version = "2.0.0" description = "A lightweight authentication and access management library for integration with OpenID Connect enabled authentication services." -category = "main" optional = false python-versions = "*" files = [ @@ -1251,7 +1193,6 @@ requests = "*" name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1263,7 +1204,6 @@ files = [ name = "openpyxl" version = "3.0.10" description = "A Python library to read/write Excel 2010 xlsx/xlsm files" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1278,7 +1218,6 @@ et-xmlfile = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1290,7 +1229,6 @@ files = [ name = "parso" version = "0.8.3" description = "A Python Parser" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1306,7 +1244,6 @@ testing = ["docopt", "pytest (<6.0.0)"] name = "pathspec" version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1318,7 +1255,6 @@ files = [ name = "pdbpp" version = "0.10.3" description = "pdb++, a drop-in replacement for pdb" -category = "dev" optional = false python-versions = "*" files = [ @@ -1339,7 +1275,6 @@ testing = ["funcsigs", "pytest"] name = "pexpect" version = "4.8.0" description = "Pexpect allows easy control of interactive console applications." -category = "dev" optional = false python-versions = "*" files = [ @@ -1354,7 +1289,6 @@ ptyprocess = ">=0.5" name = "pickleshare" version = "0.7.5" description = "Tiny 'shelve'-like database with concurrency support" -category = "dev" optional = false python-versions = "*" files = [ @@ -1366,7 +1300,6 @@ files = [ name = "pika" version = "1.1.0" description = "Pika Python AMQP Client Library" -category = "main" optional = false python-versions = "*" files = [ @@ -1382,7 +1315,6 @@ twisted = ["twisted"] name = "platformdirs" version = "3.5.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1398,7 +1330,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1414,7 +1345,6 @@ testing = ["pytest", "pytest-benchmark"] name = "prometheus-client" version = "0.16.0" description = "Python client for the Prometheus monitoring system." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1429,7 +1359,6 @@ twisted = ["twisted"] name = "prompt-toolkit" version = "3.0.38" description = "Library for building powerful interactive command lines in Python" -category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -1444,7 +1373,6 @@ wcwidth = "*" name = "psycopg2-binary" version = "2.9.6" description = "psycopg2 - Python-PostgreSQL Database Adapter" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1516,7 +1444,6 @@ files = [ name = "ptyprocess" version = "0.7.0" description = "Run a subprocess in a pseudo terminal" -category = "dev" optional = false python-versions = "*" files = [ @@ -1528,7 +1455,6 @@ files = [ name = "pure-eval" version = "0.2.2" description = "Safely evaluate AST nodes without side effects" -category = "dev" optional = false python-versions = "*" files = [ @@ -1543,7 +1469,6 @@ tests = ["pytest"] name = "py-moneyed" version = "1.2" description = "Provides Currency and Money classes for use in your Python code." -category = "main" optional = false python-versions = "*" files = [ @@ -1561,7 +1486,6 @@ tests = ["pytest (>=2.3.0)", "tox (>=1.6.0)"] name = "pycodestyle" version = "2.8.0" description = "Python style guide checker" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1573,7 +1497,6 @@ files = [ name = "pycparser" version = "2.21" description = "C parser in Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1585,7 +1508,6 @@ files = [ name = "pydocstyle" version = "6.3.0" description = "Python docstring style checker" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1603,7 +1525,6 @@ toml = ["tomli (>=1.2.3)"] name = "pyexcel" version = "0.7.0" description = "A wrapper library that provides one API to read, manipulate and writedata in different excel formats" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1626,7 +1547,6 @@ xlsx = ["pyexcel-xlsx (>=0.6.0)"] name = "pyexcel-ezodf" version = "0.3.4" description = "A Python package to create/manipulate OpenDocumentFormat files" -category = "main" optional = false python-versions = "*" files = [ @@ -1641,7 +1561,6 @@ lxml = "*" name = "pyexcel-io" version = "0.6.6" description = "A python library to read and write structured data in csv, zipped csvformat and to/from databases" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1661,7 +1580,6 @@ xlsx = ["pyexcel-xlsx (>=0.6.0)"] name = "pyexcel-ods3" version = "0.6.1" description = "A wrapper library to read, manipulate and write data in ods format" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1678,7 +1596,6 @@ pyexcel-io = ">=0.6.2" name = "pyexcel-webio" version = "0.1.4" description = "A generic request and response interface for pyexcel web extensions." -category = "main" optional = false python-versions = "*" files = [ @@ -1693,7 +1610,6 @@ pyexcel = ">=0.5.6" name = "pyexcel-xlsx" version = "0.6.0" description = "A wrapper library to read, manipulate and write data in xlsx and xlsmformat" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1709,7 +1625,6 @@ pyexcel-io = ">=0.6.2" name = "pyflakes" version = "2.4.0" description = "passive checker of Python programs" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1721,7 +1636,6 @@ files = [ name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1734,18 +1648,17 @@ plugins = ["importlib-metadata"] [[package]] name = "pyopenssl" -version = "23.1.1" +version = "23.2.0" description = "Python wrapper module around the OpenSSL library" -category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "pyOpenSSL-23.1.1-py3-none-any.whl", hash = "sha256:9e0c526404a210df9d2b18cd33364beadb0dc858a739b885677bc65e105d4a4c"}, - {file = "pyOpenSSL-23.1.1.tar.gz", hash = "sha256:841498b9bec61623b1b6c47ebbc02367c07d60e0e195f19790817f10cc8db0b7"}, + {file = "pyOpenSSL-23.2.0-py3-none-any.whl", hash = "sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2"}, + {file = "pyOpenSSL-23.2.0.tar.gz", hash = "sha256:276f931f55a452e7dea69c7173e984eb2a4407ce413c918aa34b55f82f9b8bac"}, ] [package.dependencies] -cryptography = ">=38.0.0,<41" +cryptography = ">=38.0.0,<40.0.0 || >40.0.0,<40.0.1 || >40.0.1,<42" [package.extras] docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] @@ -1755,7 +1668,6 @@ test = ["flaky", "pretend", "pytest (>=3.0.1)"] name = "pyreadline" version = "2.1" description = "A python implmementation of GNU readline." -category = "dev" optional = false python-versions = "*" files = [ @@ -1766,7 +1678,6 @@ files = [ name = "pyrepl" version = "0.9.0" description = "A library for building flexible command line interfaces" -category = "dev" optional = false python-versions = "*" files = [ @@ -1777,7 +1688,6 @@ files = [ name = "pytest" version = "7.2.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1801,7 +1711,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2. name = "pytest-cov" version = "4.0.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1820,7 +1729,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-django" version = "4.5.2" description = "A Django plugin for pytest." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1839,7 +1747,6 @@ testing = ["Django", "django-configurations (>=2.0)"] name = "pytest-env" version = "0.6.2" description = "py.test plugin that allows you to add environment variables." -category = "dev" optional = false python-versions = "*" files = [ @@ -1853,7 +1760,6 @@ pytest = ">=2.6.0" name = "pytest-factoryboy" version = "2.1.0" description = "Factory Boy support for pytest." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1870,7 +1776,6 @@ pytest = ">=4.6" name = "pytest-freezegun" version = "0.4.2" description = "Wrap tests with fixtures in freeze_time" -category = "dev" optional = false python-versions = "*" files = [ @@ -1886,7 +1791,6 @@ pytest = ">=3.0.0" name = "pytest-mock" version = "3.7.0" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1904,7 +1808,6 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "pytest-randomly" version = "3.12.0" description = "Pytest plugin to randomly order tests and control random.seed." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1920,7 +1823,6 @@ pytest = "*" name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -1935,7 +1837,6 @@ six = ">=1.5" name = "python-redmine" version = "2.4.0" description = "Library for communicating with a Redmine project management application" -category = "main" optional = false python-versions = ">=3.7, <4" files = [ @@ -1950,7 +1851,6 @@ requests = ">=2.28.2" name = "pytz" version = "2022.7.1" description = "World timezone definitions, modern and historical" -category = "main" optional = false python-versions = "*" files = [ @@ -1960,14 +1860,13 @@ files = [ [[package]] name = "requests" -version = "2.30.0" +version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"}, - {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"}, + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, ] [package.dependencies] @@ -1984,7 +1883,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "requests-mock" version = "1.9.3" description = "Mock out responses from the requests package" -category = "dev" optional = false python-versions = "*" files = [ @@ -2004,7 +1902,6 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 name = "sentry-sdk" version = "1.22.2" description = "Python client for Sentry (https://sentry.io)" -category = "main" optional = false python-versions = "*" files = [ @@ -2046,7 +1943,6 @@ tornado = ["tornado (>=5)"] name = "setuptools" version = "67.7.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2063,7 +1959,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2075,7 +1970,6 @@ files = [ name = "snapshottest" version = "0.6.0" description = "Snapshot testing for pytest, unittest, Django, and Nose" -category = "dev" optional = false python-versions = "*" files = [ @@ -2097,7 +1991,6 @@ test = ["django (>=1.10.6)", "nose", "pytest (>=4.6)", "pytest-cov", "six"] name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" optional = false python-versions = "*" files = [ @@ -2109,7 +2002,6 @@ files = [ name = "sqlparse" version = "0.4.4" description = "A non-validating SQL parser." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -2126,7 +2018,6 @@ test = ["pytest", "pytest-cov"] name = "stack-data" version = "0.6.2" description = "Extract data from python stack frames and tracebacks for informative displays" -category = "dev" optional = false python-versions = "*" files = [ @@ -2146,7 +2037,6 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] name = "termcolor" version = "2.3.0" description = "ANSI color formatting for output in terminal" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2161,7 +2051,6 @@ tests = ["pytest", "pytest-cov"] name = "testfixtures" version = "6.18.5" description = "A collection of helpers and mock objects for unit tests and doc tests." -category = "dev" optional = false python-versions = "*" files = [ @@ -2178,7 +2067,6 @@ test = ["django", "django (<2)", "mock", "pytest (>=3.6)", "pytest-cov", "pytest name = "texttable" version = "1.6.7" description = "module to create simple ASCII tables" -category = "main" optional = false python-versions = "*" files = [ @@ -2190,7 +2078,6 @@ files = [ name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2202,7 +2089,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2212,30 +2098,28 @@ files = [ [[package]] name = "tornado" -version = "6.3.1" +version = "6.3.2" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -category = "main" optional = false python-versions = ">= 3.8" files = [ - {file = "tornado-6.3.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:db181eb3df8738613ff0a26f49e1b394aade05034b01200a63e9662f347d4415"}, - {file = "tornado-6.3.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b4e7b956f9b5e6f9feb643ea04f07e7c6b49301e03e0023eedb01fa8cf52f579"}, - {file = "tornado-6.3.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9661aa8bc0e9d83d757cd95b6f6d1ece8ca9fd1ccdd34db2de381e25bf818233"}, - {file = "tornado-6.3.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81c17e0cc396908a5e25dc8e9c5e4936e6dfd544c9290be48bd054c79bcad51e"}, - {file = "tornado-6.3.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27a1cfa9997923f80bdd962b3aab048ac486ad8cfb2f237964f8ab7f7eb824b"}, - {file = "tornado-6.3.1-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d7117f3c7ba5d05813b17a1f04efc8e108a1b811ccfddd9134cc68553c414864"}, - {file = "tornado-6.3.1-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:ffdce65a281fd708da5a9def3bfb8f364766847fa7ed806821a69094c9629e8a"}, - {file = "tornado-6.3.1-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:90f569a35a8ec19bde53aa596952071f445da678ec8596af763b9b9ce07605e6"}, - {file = "tornado-6.3.1-cp38-abi3-win32.whl", hash = "sha256:3455133b9ff262fd0a75630af0a8ee13564f25fb4fd3d9ce239b8a7d3d027bf8"}, - {file = "tornado-6.3.1-cp38-abi3-win_amd64.whl", hash = "sha256:1285f0691143f7ab97150831455d4db17a267b59649f7bd9700282cba3d5e771"}, - {file = "tornado-6.3.1.tar.gz", hash = "sha256:5e2f49ad371595957c50e42dd7e5c14d64a6843a3cf27352b69c706d1b5918af"}, + {file = "tornado-6.3.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:c367ab6c0393d71171123ca5515c61ff62fe09024fa6bf299cd1339dc9456829"}, + {file = "tornado-6.3.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b46a6ab20f5c7c1cb949c72c1994a4585d2eaa0be4853f50a03b5031e964fc7c"}, + {file = "tornado-6.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2de14066c4a38b4ecbbcd55c5cc4b5340eb04f1c5e81da7451ef555859c833f"}, + {file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05615096845cf50a895026f749195bf0b10b8909f9be672f50b0fe69cba368e4"}, + {file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b17b1cf5f8354efa3d37c6e28fdfd9c1c1e5122f2cb56dac121ac61baa47cbe"}, + {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:29e71c847a35f6e10ca3b5c2990a52ce38b233019d8e858b755ea6ce4dcdd19d"}, + {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:834ae7540ad3a83199a8da8f9f2d383e3c3d5130a328889e4cc991acc81e87a0"}, + {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6a0848f1aea0d196a7c4f6772197cbe2abc4266f836b0aac76947872cd29b411"}, + {file = "tornado-6.3.2-cp38-abi3-win32.whl", hash = "sha256:7efcbcc30b7c654eb6a8c9c9da787a851c18f8ccd4a5a3a95b05c7accfa068d2"}, + {file = "tornado-6.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:0c325e66c8123c606eea33084976c832aa4e766b7dff8aedd7587ea44a604cdf"}, + {file = "tornado-6.3.2.tar.gz", hash = "sha256:4b927c4f19b71e627b13f3db2324e4ae660527143f9e1f2e2fb404f3a187e2ba"}, ] [[package]] name = "traitlets" version = "5.9.0" description = "Traitlets Python configuration system" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2251,7 +2135,6 @@ test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] name = "typing-extensions" version = "4.5.0" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2263,7 +2146,6 @@ files = [ name = "urllib3" version = "1.26.15" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -2280,7 +2162,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "wasmer" version = "1.1.0" description = "Python extension to run WebAssembly binaries" -category = "dev" optional = false python-versions = "*" files = [ @@ -2304,7 +2185,6 @@ files = [ name = "wasmer-compiler-cranelift" version = "1.1.0" description = "The Cranelift compiler for the `wasmer` package (to compile WebAssembly module)" -category = "dev" optional = false python-versions = "*" files = [ @@ -2328,7 +2208,6 @@ files = [ name = "wcwidth" version = "0.2.6" description = "Measures the displayed width of unicode strings in a terminal" -category = "dev" optional = false python-versions = "*" files = [ @@ -2340,7 +2219,6 @@ files = [ name = "whitenoise" version = "6.4.0" description = "Radically simplified static file serving for WSGI applications" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2355,7 +2233,6 @@ brotli = ["Brotli"] name = "wmctrl" version = "0.4" description = "A tool to programmatically control windows inside X" -category = "dev" optional = false python-versions = "*" files = [ @@ -2366,7 +2243,6 @@ files = [ name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false python-versions = ">=3.7" files = [ From 3e06680849f9398e417c91fbd62d03214df2574d Mon Sep 17 00:00:00 2001 From: Wiktor Kepczynski <55092761+trowik@users.noreply.github.com> Date: Thu, 15 Jun 2023 09:45:32 +0200 Subject: [PATCH 976/980] chore(release): v3.0.7 (#975) --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6c37199..f21689cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v3.0.7 + +### Fix +* **redmine:** Convert Decimal objects to floats ([`5f613d1`](https://github.com/adfinis/timed-backend/commit/5f613d1e83f007270228c109846fb6a525eced71)) + # v3.0.6 ### Feature diff --git a/pyproject.toml b/pyproject.toml index 79edd019f..904102ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "timed-backend" -version = "3.0.6" +version = "3.0.7" description = "Timetracking software" repository = "https://github.com/adfinis/timed-backend" authors = ["Adfinis AG"] From c4822fff94940eb5de54ee48a673f3c3631df31e Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 28 Jun 2023 10:54:02 +0200 Subject: [PATCH 977/980] chore: update dependencies and format code --- poetry.lock | 493 +++++++++--------- pyproject.toml | 46 +- timed/conftest.py | 1 - timed/employment/filters.py | 1 - timed/employment/migrations/0001_initial.py | 1 - .../migrations/0002_auto_20170823_1051.py | 1 - .../migrations/0003_user_tour_done.py | 1 - .../migrations/0004_auto_20170904_1510.py | 1 - .../migrations/0005_auto_20170906_1259.py | 1 - .../migrations/0006_auto_20170906_1635.py | 1 - .../migrations/0007_auto_20170911_0959.py | 1 - .../migrations/0008_auto_20171013_1041.py | 1 - .../migrations/0009_delete_userabsencetype.py | 1 - .../migrations/0010_overtimecredit_comment.py | 1 - .../migrations/0011_auto_20171101_1227.py | 1 - .../migrations/0012_auto_20181026_1528.py | 1 - .../migrations/0013_auto_20210302_1136.py | 1 - .../migrations/0014_employment_is_external.py | 1 - .../migrations/0015_user_is_accountant.py | 1 - timed/employment/serializers.py | 1 - .../employment/tests/test_worktime_balance.py | 2 - .../notifications/migrations/0001_initial.py | 1 - ...02_alter_notification_notification_type.py | 1 - timed/projects/migrations/0001_initial.py | 1 - .../migrations/0002_auto_20170823_1045.py | 1 - .../migrations/0003_auto_20170831_1624.py | 1 - .../migrations/0004_auto_20170906_1045.py | 1 - .../migrations/0005_auto_20170907_0938.py | 1 - .../migrations/0006_auto_20171010_1423.py | 1 - .../0007_project_subscription_project.py | 1 - .../migrations/0008_auto_20190220_1133.py | 1 - .../migrations/0009_auto_20201201_1412.py | 1 - .../migrations/0010_project_billed.py | 1 - .../migrations/0011_auto_20210419_1459.py | 1 - .../0012_migrate_reviewers_to_assignees.py | 1 - .../0013_remove_project_reviewers.py | 1 - .../0014_add_is_customer_role_to_assignees.py | 1 - .../0015_remaining_effort_task_project.py | 1 - timed/redmine/migrations/0001_initial.py | 1 - timed/reports/filters.py | 1 - .../reports/tests/test_customer_statistic.py | 3 +- timed/reports/tests/test_task_statistic.py | 1 - timed/reports/views.py | 1 - timed/subscription/migrations/0001_initial.py | 1 - .../migrations/0002_auto_20170808_1729.py | 1 - .../migrations/0003_auto_20170907_1151.py | 1 - .../migrations/0004_auto_20200407_2052.py | 1 - .../0005_alter_package_price_currency.py | 1 - .../0006_alter_package_price_currency.py | 1 - timed/subscription/serializers.py | 1 - timed/tracking/migrations/0001_initial.py | 1 - .../migrations/0002_auto_20170912_1346.py | 1 - .../migrations/0003_auto_20170912_1347.py | 1 - .../migrations/0004_auto_20171005_1057.py | 1 - .../0005_remove_absence_duration.py | 1 - .../migrations/0006_add_activity_time.py | 1 - .../0007_migrate_activity_blocks.py | 1 - .../migrations/0008_delete_activity_blocks.py | 1 - .../migrations/0009_remove_report_activity.py | 1 - .../migrations/0010_auto_20180904_0818.py | 1 - .../migrations/0011_auto_20181026_1528.py | 1 - .../0012_migrate_report_review_false.py | 1 - .../tracking/migrations/0013_report_billed.py | 1 - .../0014_rename_type_absence_absence_type.py | 1 - .../migrations/0015_report_rejected.py | 1 - .../0016_report_remaining_effort.py | 1 - .../0017_alter_report_remaining_effort.py | 1 - 67 files changed, 260 insertions(+), 347 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1054696b8..fae3a0873 100644 --- a/poetry.lock +++ b/poetry.lock @@ -42,24 +42,6 @@ six = "*" [package.extras] test = ["astroid", "pytest"] -[[package]] -name = "attrs" -version = "23.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, - {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] - [[package]] name = "babel" version = "2.12.1" @@ -71,9 +53,6 @@ files = [ {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [[package]] name = "backcall" version = "0.2.0" @@ -87,39 +66,42 @@ files = [ [[package]] name = "black" -version = "22.3.0" +version = "23.3.0" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.6.2" -files = [ - {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, - {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, - {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, - {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, - {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, - {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, - {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, - {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, - {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, - {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, - {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, - {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, - {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, - {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, - {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, - {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, - {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, - {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, - {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, +python-versions = ">=3.7" +files = [ + {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, + {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, + {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, + {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, + {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, + {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, + {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, + {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, + {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, + {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, + {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, + {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, + {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, + {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, + {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, + {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, + {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, + {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, + {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, + {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, + {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, + {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, + {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, + {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, + {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" +packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} @@ -340,52 +322,71 @@ files = [ [[package]] name = "coverage" -version = "6.4.1" +version = "7.2.7" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, - {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, - {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, - {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, - {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, - {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, - {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, - {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, - {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, - {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, - {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, - {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, - {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, - {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, - {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, - {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, - {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, ] [package.dependencies] @@ -468,13 +469,13 @@ bcrypt = ["bcrypt"] [[package]] name = "django-cors-headers" -version = "3.14.0" +version = "4.1.0" description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." optional = false python-versions = ">=3.7" files = [ - {file = "django_cors_headers-3.14.0-py3-none-any.whl", hash = "sha256:684180013cc7277bdd8702b80a3c5a4b3fcae4abb2bf134dceb9f5dfe300228e"}, - {file = "django_cors_headers-3.14.0.tar.gz", hash = "sha256:5fbd58a6fb4119d975754b2bc090f35ec160a8373f276612c675b00e8a138739"}, + {file = "django_cors_headers-4.1.0-py3-none-any.whl", hash = "sha256:88a4bfae24b6404dd0e0640203cb27704a2a57fd546a429e5d821dfa53dd1acf"}, + {file = "django_cors_headers-4.1.0.tar.gz", hash = "sha256:36a8d7a6dee6a85f872fe5916cc878a36d0812043866355438dfeda0b20b6b78"}, ] [package.dependencies] @@ -482,13 +483,13 @@ Django = ">=3.2" [[package]] name = "django-environ" -version = "0.8.1" +version = "0.10.0" description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." optional = false -python-versions = ">=3.4,<4" +python-versions = ">=3.5,<4" files = [ - {file = "django-environ-0.8.1.tar.gz", hash = "sha256:6f0bc902b43891656b20486938cba0861dc62892784a44919170719572a534cb"}, - {file = "django_environ-0.8.1-py2.py3-none-any.whl", hash = "sha256:42593bee519a527602a467c7b682aee1a051c2597f98c45f4f4f44169ecdb6e5"}, + {file = "django-environ-0.10.0.tar.gz", hash = "sha256:b3559a91439c9d774a9e0c1ced872364772c612cdf6dc919506a2b13f7a77225"}, + {file = "django_environ-0.10.0-py2.py3-none-any.whl", hash = "sha256:510f8c9c1d0a38b0815f91504270c29440a0cf44fab07f55942fa8d31bbb9be6"}, ] [package.extras] @@ -519,13 +520,13 @@ xlsx = ["pyexcel-xlsx (>=0.4.0)"] [[package]] name = "django-extensions" -version = "3.2.1" +version = "3.2.3" description = "Extensions for Django" optional = false python-versions = ">=3.6" files = [ - {file = "django-extensions-3.2.1.tar.gz", hash = "sha256:2a4f4d757be2563cd1ff7cfdf2e57468f5f931cc88b23cf82ca75717aae504a4"}, - {file = "django_extensions-3.2.1-py3-none-any.whl", hash = "sha256:421464be390289513f86cb5e18eb43e5dc1de8b4c27ba9faa3b91261b0d67e09"}, + {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"}, + {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"}, ] [package.dependencies] @@ -533,17 +534,17 @@ Django = ">=3.2" [[package]] name = "django-filter" -version = "21.1" +version = "23.2" description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "django-filter-21.1.tar.gz", hash = "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e"}, - {file = "django_filter-21.1-py3-none-any.whl", hash = "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"}, + {file = "django-filter-23.2.tar.gz", hash = "sha256:2fe15f78108475eda525692813205fa6f9e8c1caf1ae65daa5862d403c6dbf00"}, + {file = "django_filter-23.2-py3-none-any.whl", hash = "sha256:d12d8e0fc6d3eb26641e553e5d53b191eb8cec611427d4bdce0becb1f7c172b5"}, ] [package.dependencies] -Django = ">=2.2" +Django = ">=3.2" [[package]] name = "django-hurricane" @@ -569,23 +570,23 @@ pycharm = ["pydevd-pycharm (>=213.5605.23,<213.5606.0)"] [[package]] name = "django-money" -version = "2.1.1" +version = "3.1.0" description = "Adds support for using money and currency fields in django models and forms. Uses py-moneyed as the money implementation." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "django-money-2.1.1.tar.gz", hash = "sha256:4d06041fac5c565ad049a7f8fb4bc33de5c68047b0693efa18a9931cebffb606"}, - {file = "django_money-2.1.1-py3-none-any.whl", hash = "sha256:60a605d5b999e1756a18008dd7e0c5a860fd64c018a140c7a7675a4209ec3782"}, + {file = "django-money-3.1.0.tar.gz", hash = "sha256:06a9257fa784576f5a0885e9b179065e3d4da4797876fa0a4f310de06b6dc65b"}, + {file = "django_money-3.1.0-py3-none-any.whl", hash = "sha256:e2d3cd025704dc00fcdf05733273299db5f8a6519335ad79a82c9d4269da6789"}, ] [package.dependencies] Django = ">=2.2" -py-moneyed = ">=1.2,<2.0" +py-moneyed = ">=2.0,<3.0" setuptools = "*" [package.extras] exchange = ["certifi"] -test = ["mixer", "pytest (>=3.1.0)", "pytest-cov", "pytest-django", "pytest-pythonpath"] +test = ["django-stubs", "mixer", "mypy", "pytest (>=3.1.0)", "pytest-cov", "pytest-django", "pytest-pythonpath"] [[package]] name = "django-multiselectfield" @@ -628,33 +629,33 @@ prometheus-client = ">=0.7" [[package]] name = "djangorestframework" -version = "3.13.1" +version = "3.14.0" description = "Web APIs for Django, made easy." optional = false python-versions = ">=3.6" files = [ - {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, - {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, + {file = "djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08"}, + {file = "djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8"}, ] [package.dependencies] -django = ">=2.2" +django = ">=3.0" pytz = "*" [[package]] name = "djangorestframework-jsonapi" -version = "5.0.0" +version = "6.0.0" description = "A Django REST framework API adapter for the JSON:API spec." optional = false python-versions = ">=3.7" files = [ - {file = "djangorestframework-jsonapi-5.0.0.tar.gz", hash = "sha256:090c568dc99380ead71cc378020b4cd191db2ffce9ab3e9339df80d5d82c8648"}, - {file = "djangorestframework_jsonapi-5.0.0-py2.py3-none-any.whl", hash = "sha256:f25b0d24a990690e578668b7a7a191a75162f1d9561abd773d12de331cf16673"}, + {file = "djangorestframework-jsonapi-6.0.0.tar.gz", hash = "sha256:f2465b1b1cd3f372abacc8d99f82835643373f4f3f12965276ad1ccc2d110415"}, + {file = "djangorestframework_jsonapi-6.0.0-py2.py3-none-any.whl", hash = "sha256:a93b3678bd5e2f070946ca32d7d0bb3734cb5966a80f8a44fa721fcf15cf89ce"}, ] [package.dependencies] -django = ">=2.2,<4.1" -djangorestframework = ">=3.12,<3.14" +django = ">=3.2,<4.2" +djangorestframework = ">=3.13,<3.15" inflection = ">=0.5.0" [package.extras] @@ -721,13 +722,13 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "18.7.0" +version = "18.11.2" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.7" files = [ - {file = "Faker-18.7.0-py3-none-any.whl", hash = "sha256:38dbc3b80e655d7301e190426ab30f04b6b7f6ca4764c5dd02772ffde0fa6dcd"}, - {file = "Faker-18.7.0.tar.gz", hash = "sha256:f02c6d3fdb5bc781f80b440cf2bdec336ed47ecfb8d620b20c3d4188ed051831"}, + {file = "Faker-18.11.2-py3-none-any.whl", hash = "sha256:21c2c29638e98502f3bba9ad6a4f07a4b09c5e2150bb491ff02411a5888f6955"}, + {file = "Faker-18.11.2.tar.gz", hash = "sha256:ec6e2824bb1d3546b36c156324b9df6bca5a3d6d03adf991e6a5586756dcab9d"}, ] [package.dependencies] @@ -765,19 +766,19 @@ wasmer-compiler-cranelift = ">=1.0.0" [[package]] name = "flake8" -version = "4.0.1" +version = "6.0.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8.1" files = [ - {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, - {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, + {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, + {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, ] [package.dependencies] -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.8.0,<2.9.0" -pyflakes = ">=2.4.0,<2.5.0" +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.10.0,<2.11.0" +pyflakes = ">=3.0.0,<3.1.0" [[package]] name = "flake8-blind-except" @@ -806,27 +807,30 @@ pycodestyle = "*" [[package]] name = "flake8-deprecated" -version = "1.3" +version = "2.0.1" description = "Warns about deprecated method calls." optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "flake8-deprecated-1.3.tar.gz", hash = "sha256:9fa5a0c5c81fb3b34c53a0e4f16cd3f0a3395078cfd4988011cbab5fb0afa7f7"}, - {file = "flake8_deprecated-1.3-py2.py3-none-any.whl", hash = "sha256:211951854837ced9ec997a75c6e5b957f3536a735538ee0620b76539fd3706cd"}, + {file = "flake8-deprecated-2.0.1.tar.gz", hash = "sha256:c7659a530aa76c3ad8be0c1e8331ed56d882ef8bfba074501a545bb3352b0c23"}, + {file = "flake8_deprecated-2.0.1-py3-none-any.whl", hash = "sha256:8c61d2cb8d487118b6c20392b25f08ba1ec49c759e4ea562c7a60172912bc7ee"}, ] [package.dependencies] -flake8 = ">=3.0.0" +flake8 = "*" + +[package.extras] +test = ["pytest"] [[package]] name = "flake8-docstrings" -version = "1.6.0" +version = "1.7.0" description = "Extension for flake8 which uses pydocstyle to check docstrings" optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, - {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, + {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, + {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, ] [package.dependencies] @@ -835,22 +839,21 @@ pydocstyle = ">=2.1" [[package]] name = "flake8-isort" -version = "4.1.1" +version = "6.0.0" description = "flake8 plugin that integrates isort ." optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, - {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, + {file = "flake8-isort-6.0.0.tar.gz", hash = "sha256:537f453a660d7e903f602ecfa36136b140de279df58d02eb1b6a0c84e83c528c"}, + {file = "flake8_isort-6.0.0-py3-none-any.whl", hash = "sha256:aa0cac02a62c7739e370ce6b9c31743edac904bae4b157274511fc8a19c75bbc"}, ] [package.dependencies] -flake8 = ">=3.2.1,<5" -isort = ">=4.3.5,<6" -testfixtures = ">=6.8.0,<7" +flake8 = "*" +isort = ">=5.0.0,<6" [package.extras] -test = ["pytest-cov"] +test = ["pytest"] [[package]] name = "flake8-string-format" @@ -893,13 +896,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.6.0" +version = "6.7.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, - {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, + {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, + {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, ] [package.dependencies] @@ -908,7 +911,7 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "inflection" @@ -934,29 +937,29 @@ files = [ [[package]] name = "ipdb" -version = "0.13.9" +version = "0.13.13" description = "IPython-enabled pdb" optional = false -python-versions = ">=2.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ - {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, + {file = "ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4"}, + {file = "ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726"}, ] [package.dependencies] decorator = {version = "*", markers = "python_version > \"3.6\""} -ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""} -setuptools = "*" -toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} +tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} [[package]] name = "ipython" -version = "8.12.2" +version = "8.14.0" description = "IPython: Productive Interactive Computing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "ipython-8.12.2-py3-none-any.whl", hash = "sha256:ea8801f15dfe4ffb76dea1b09b847430ffd70d827b41735c64a0638a04103bfc"}, - {file = "ipython-8.12.2.tar.gz", hash = "sha256:c7b80eb7f5a855a88efc971fda506ff7a91c280b42cdae26643e0f601ea281ea"}, + {file = "ipython-8.14.0-py3-none-any.whl", hash = "sha256:248aca623f5c99a6635bc3857677b7320b9b8039f99f070ee0d20a5ca5a8e6bf"}, + {file = "ipython-8.14.0.tar.gz", hash = "sha256:1d197b907b6ba441b692c48cf2a3a2de280dc0ac91a3405b39349a50272ca0a1"}, ] [package.dependencies] @@ -989,18 +992,18 @@ test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pa [[package]] name = "isort" -version = "5.10.1" +version = "5.12.0" description = "A Python utility / library to sort Python imports." optional = false -python-versions = ">=3.6.1,<4.0" +python-versions = ">=3.8.0" files = [ - {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, - {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, ] [package.extras] -colors = ["colorama (>=0.4.3,<0.5.0)"] -pipfile-deprecated-finder = ["pipreqs", "requirementslib"] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] plugins = ["setuptools"] requirements-deprecated-finder = ["pip-api", "pipreqs"] @@ -1163,29 +1166,29 @@ traitlets = "*" [[package]] name = "mccabe" -version = "0.6.1" +version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] [[package]] name = "mozilla-django-oidc" -version = "2.0.0" +version = "3.0.0" description = "A lightweight authentication and access management library for integration with OpenID Connect enabled authentication services." optional = false python-versions = "*" files = [ - {file = "mozilla-django-oidc-2.0.0.tar.gz", hash = "sha256:a8b2f27c69c122d2f4d801c3759761d33debf06ae9dabbab8aed82887bba3bb8"}, - {file = "mozilla_django_oidc-2.0.0-py2.py3-none-any.whl", hash = "sha256:53c39755b667e8c5923b1dffc3c29673198d03aa107aa42ac86b8a38b4720c25"}, + {file = "mozilla-django-oidc-3.0.0.tar.gz", hash = "sha256:a7d447af83cb5aa1671a24009b0ce6b2f0d259e9c58d8c88c7a8d0c27c05c04d"}, + {file = "mozilla_django_oidc-3.0.0-py2.py3-none-any.whl", hash = "sha256:f535eeddf03698ad9fd89dd87037828e9c7d503771acef21f0509f6cc42fc875"}, ] [package.dependencies] cryptography = "*" -Django = ">=2.2" +Django = ">=3.2" josepy = "*" requests = "*" @@ -1313,28 +1316,28 @@ twisted = ["twisted"] [[package]] name = "platformdirs" -version = "3.5.0" +version = "3.8.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"}, - {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"}, + {file = "platformdirs-3.8.0-py3-none-any.whl", hash = "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e"}, + {file = "platformdirs-3.8.0.tar.gz", hash = "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc"}, ] [package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" -version = "1.0.0" +version = "1.2.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] [package.extras] @@ -1343,13 +1346,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "prometheus-client" -version = "0.16.0" +version = "0.17.0" description = "Python client for the Prometheus monitoring system." optional = false python-versions = ">=3.6" files = [ - {file = "prometheus_client-0.16.0-py3-none-any.whl", hash = "sha256:0836af6eb2c8f4fed712b2f279f6c0a8bbab29f9f4aa15276b91c7cb0d1616ab"}, - {file = "prometheus_client-0.16.0.tar.gz", hash = "sha256:a03e35b359f14dd1630898543e2120addfdeacd1a6069c1367ae90fd93ad3f48"}, + {file = "prometheus_client-0.17.0-py3-none-any.whl", hash = "sha256:a77b708cf083f4d1a3fb3ce5c95b4afa32b9c521ae363354a4a910204ea095ce"}, + {file = "prometheus_client-0.17.0.tar.gz", hash = "sha256:9c3b26f1535945e85b8934fb374678d263137b78ef85f305b1156c7c881cd11b"}, ] [package.extras] @@ -1467,30 +1470,32 @@ tests = ["pytest"] [[package]] name = "py-moneyed" -version = "1.2" +version = "2.0" description = "Provides Currency and Money classes for use in your Python code." optional = false -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "py-moneyed-1.2.tar.gz", hash = "sha256:d745a52819604f42b3666f9b2504b71c27c1645d6d5027d95ec5ed1f28ca86e3"}, - {file = "py_moneyed-1.2-py2.py3-none-any.whl", hash = "sha256:c6131c7b7c1f8503552afe44d15c343ea50282d1d9e6fa8b3f1bd2affc1dae1e"}, + {file = "py-moneyed-2.0.tar.gz", hash = "sha256:a56e1987deacb2e0eac5904552699a5d3fa251042e528bf2ff74a72359f5e5b3"}, + {file = "py_moneyed-2.0-py3-none-any.whl", hash = "sha256:1fafe552cfa3cba579d026924c27b070d71b4140e50ef4535e4083b3f4f2473f"}, ] [package.dependencies] babel = ">=2.8.0" +typing-extensions = ">=3.7.4.3" [package.extras] tests = ["pytest (>=2.3.0)", "tox (>=1.6.0)"] +type-tests = ["mypy (>=0.812)", "pytest (>=2.3.0)", "pytest-mypy-plugins"] [[package]] name = "pycodestyle" -version = "2.8.0" +version = "2.10.0" description = "Python style guide checker" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" files = [ - {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, - {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, + {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, + {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, ] [[package]] @@ -1623,13 +1628,13 @@ pyexcel-io = ">=0.6.2" [[package]] name = "pyflakes" -version = "2.4.0" +version = "3.0.1" description = "passive checker of Python programs" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" files = [ - {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, - {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, + {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, + {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, ] [[package]] @@ -1686,17 +1691,16 @@ files = [ [[package]] name = "pytest" -version = "7.2.1" +version = "7.4.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, - {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" @@ -1705,7 +1709,7 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" @@ -1849,13 +1853,13 @@ requests = ">=2.28.2" [[package]] name = "pytz" -version = "2022.7.1" +version = "2023.3" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, - {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, + {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, + {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, ] [[package]] @@ -1900,18 +1904,18 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 [[package]] name = "sentry-sdk" -version = "1.22.2" +version = "1.26.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.22.2.tar.gz", hash = "sha256:5932c092c6e6035584eb74d77064e4bce3b7935dfc4a331349719a40db265840"}, - {file = "sentry_sdk-1.22.2-py2.py3-none-any.whl", hash = "sha256:cf89a5063ef84278d186aceaed6fb595bfe67d099298e537634a323664265669"}, + {file = "sentry-sdk-1.26.0.tar.gz", hash = "sha256:760e4fb6d01c994110507133e08ecd4bdf4d75ee4be77f296a3579796cf73134"}, + {file = "sentry_sdk-1.26.0-py2.py3-none-any.whl", hash = "sha256:0c9f858337ec3781cf4851972ef42bba8c9828aea116b0dbed8f38c5f9a1896c"}, ] [package.dependencies] certifi = "*" -urllib3 = {version = ">=1.26.11,<2.0.0", markers = "python_version >= \"3.6\""} +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} [package.extras] aiohttp = ["aiohttp (>=3.5)"] @@ -1923,10 +1927,11 @@ chalice = ["chalice (>=1.16.0)"] django = ["django (>=1.8)"] falcon = ["falcon (>=1.4)"] fastapi = ["fastapi (>=0.79.0)"] -flask = ["blinker (>=1.1)", "flask (>=0.11)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] grpcio = ["grpcio (>=1.21.1)"] httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] +loguru = ["loguru (>=0.5)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] pure-eval = ["asttokens", "executing", "pure-eval"] pymongo = ["pymongo (>=3.1)"] @@ -1941,18 +1946,18 @@ tornado = ["tornado (>=5)"] [[package]] name = "setuptools" -version = "67.7.2" +version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, - {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, + {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, + {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -2047,22 +2052,6 @@ files = [ [package.extras] tests = ["pytest", "pytest-cov"] -[[package]] -name = "testfixtures" -version = "6.18.5" -description = "A collection of helpers and mock objects for unit tests and doc tests." -optional = false -python-versions = "*" -files = [ - {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"}, - {file = "testfixtures-6.18.5.tar.gz", hash = "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84"}, -] - -[package.extras] -build = ["setuptools-git", "twine", "wheel"] -docs = ["django", "django (<2)", "mock", "sphinx", "sybil", "twisted", "zope.component"] -test = ["django", "django (<2)", "mock", "pytest (>=3.6)", "pytest-cov", "pytest-django", "sybil", "twisted", "zope.component"] - [[package]] name = "texttable" version = "1.6.7" @@ -2074,17 +2063,6 @@ files = [ {file = "texttable-1.6.7.tar.gz", hash = "sha256:290348fb67f7746931bcdfd55ac7584ecd4e5b0846ab164333f0794b121760f2"}, ] -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -2133,30 +2111,31 @@ test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] [[package]] name = "typing-extensions" -version = "4.5.0" +version = "4.6.3" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, + {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, + {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, ] [[package]] name = "urllib3" -version = "1.26.15" +version = "2.0.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.7" files = [ - {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, - {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, + {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"}, + {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "wasmer" @@ -2217,13 +2196,13 @@ files = [ [[package]] name = "whitenoise" -version = "6.4.0" +version = "6.5.0" description = "Radically simplified static file serving for WSGI applications" optional = false python-versions = ">=3.7" files = [ - {file = "whitenoise-6.4.0-py3-none-any.whl", hash = "sha256:599dc6ca57e48929dfeffb2e8e187879bfe2aed0d49ca419577005b7f2cc930b"}, - {file = "whitenoise-6.4.0.tar.gz", hash = "sha256:a02d6660ad161ff17e3042653c8e3f5ecbb2a2481a006bde125b9efb9a30113a"}, + {file = "whitenoise-6.5.0-py3-none-any.whl", hash = "sha256:16468e9ad2189f09f4a8c635a9031cc9bb2cdbc8e5e53365407acf99f7ade9ec"}, + {file = "whitenoise-6.5.0.tar.gz", hash = "sha256:15fe60546ac975b58e357ccaeb165a4ca2d0ab697e48450b8f0307ca368195a8"}, ] [package.extras] @@ -2256,5 +2235,5 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" -python-versions = "^3.8" -content-hash = "81c83402adc3a82cd39f8212b2e93eff96c877e7381a220d9cf020104bca5746" +python-versions = "^3.9" +content-hash = "4c8cc15797080b8131b35c7f79381793e76714ef0db342e5e84804b76873bb71" diff --git a/pyproject.toml b/pyproject.toml index 904102ec9..2f4287723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,19 +19,19 @@ classifiers = [ include = ["CHANGELOG.md"] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" python-dateutil = "^2.8.2" django = "^3.2.19" # might remove this once we find out how the jsonapi extras_require work -django-cors-headers = "^3.13.0" -django-filter = "^21.1" +django-cors-headers = "^4.1.0" +django-filter = "^23.2" django-multiselectfield = "^0.1.12" -django-prometheus = "^2.2.0" -djangorestframework = "^3.13.1" -djangorestframework-jsonapi = "^5.0.0" -mozilla-django-oidc = "^2.0.0" +django-prometheus = "^2.3.1" +djangorestframework = "^3.14.0" +djangorestframework-jsonapi = "^6.0.0" +mozilla-django-oidc = "^3.0.0" psycopg2-binary = "^2.9.3" -pytz = "^2022.1" +pytz = "^2023.3" pyexcel-webio = "^0.1.4" pyexcel-io = "^0.6.6" django-excel = "^0.0.10" @@ -39,30 +39,30 @@ django-nested-inline = "^0.4.5" pyexcel-ods3 = "^0.6.1" pyexcel-xlsx = "^0.6.0" pyexcel-ezodf = "^0.3.4" -django-environ = "^0.8.1" -django-money = "^2.1.1" -python-redmine = "^2.3.0" -sentry-sdk = "^1.9.5" -whitenoise = "^6.2.0" +django-environ = "^0.10.0" +django-money = "^3.1.0" +python-redmine = "^2.4.0" +sentry-sdk = "^1.26.0" +whitenoise = "^6.5.0" django-hurricane = "^1.3.4" openpyxl = "3.0.10" # TODO: dependency of `pyexcel-xlsx` Remove as soon as https://github.com/pyexcel/pyexcel-xlsx/issues/52 is resolved. [tool.poetry.dev-dependencies] -black = "22.3.0" -coverage = "6.4.1" -django-extensions = "3.2.1" +black = "23.3.0" +coverage = "7.2.7" +django-extensions = "3.2.3" factory-boy = "3.2.1" -flake8 = "4.0.1" +flake8 = "6.0.0" flake8-blind-except = "0.2.1" flake8-debugger = "4.1.2" -flake8-deprecated = "1.3" -flake8-docstrings = "1.6.0" -flake8-isort = "4.1.1" +flake8-deprecated = "2.0.1" +flake8-docstrings = "1.7.0" +flake8-isort = "6.0.0" flake8-string-format = "0.3.0" -ipdb = "0.13.9" -isort = "5.10.1" +ipdb = "0.13.13" +isort = "5.12.0" pdbpp = "0.10.3" -pytest = "7.2.1" +pytest = "7.4.0" pytest-cov = "4.0.0" pytest-django = "4.5.2" pytest-env = "0.6.2" diff --git a/timed/conftest.py b/timed/conftest.py index 1e128ee71..725c82234 100644 --- a/timed/conftest.py +++ b/timed/conftest.py @@ -161,7 +161,6 @@ def setup_customer_and_employment_status( user=user, is_customer=is_customer ) if is_employed: - employment = employment_factories.EmploymentFactory.create( user=user, is_external=is_external ) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 691cc4adf..c74a8b138 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -76,7 +76,6 @@ class EmploymentFilterSet(FilterSet): date = DateFilter(method="filter_date") def filter_date(self, queryset, name, value): - queryset = queryset.filter( Q(start_date__lte=value) & Q(Q(end_date__gte=value) | Q(end_date__isnull=True)) diff --git a/timed/employment/migrations/0001_initial.py b/timed/employment/migrations/0001_initial.py index 2e6ecd854..10770c7bc 100644 --- a/timed/employment/migrations/0001_initial.py +++ b/timed/employment/migrations/0001_initial.py @@ -13,7 +13,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [("auth", "0008_alter_user_username_max_length")] diff --git a/timed/employment/migrations/0002_auto_20170823_1051.py b/timed/employment/migrations/0002_auto_20170823_1051.py index 5c6bc0a77..e72625ce2 100644 --- a/timed/employment/migrations/0002_auto_20170823_1051.py +++ b/timed/employment/migrations/0002_auto_20170823_1051.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0001_initial")] operations = [ diff --git a/timed/employment/migrations/0003_user_tour_done.py b/timed/employment/migrations/0003_user_tour_done.py index 753ad4764..ddf2c3ae8 100644 --- a/timed/employment/migrations/0003_user_tour_done.py +++ b/timed/employment/migrations/0003_user_tour_done.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0002_auto_20170823_1051")] operations = [ diff --git a/timed/employment/migrations/0004_auto_20170904_1510.py b/timed/employment/migrations/0004_auto_20170904_1510.py index c84540345..49adf55f0 100644 --- a/timed/employment/migrations/0004_auto_20170904_1510.py +++ b/timed/employment/migrations/0004_auto_20170904_1510.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0003_user_tour_done")] operations = [ diff --git a/timed/employment/migrations/0005_auto_20170906_1259.py b/timed/employment/migrations/0005_auto_20170906_1259.py index f22139732..e630ee6f3 100644 --- a/timed/employment/migrations/0005_auto_20170906_1259.py +++ b/timed/employment/migrations/0005_auto_20170906_1259.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0004_auto_20170904_1510")] operations = [ diff --git a/timed/employment/migrations/0006_auto_20170906_1635.py b/timed/employment/migrations/0006_auto_20170906_1635.py index 82761ef09..98ab8f8af 100644 --- a/timed/employment/migrations/0006_auto_20170906_1635.py +++ b/timed/employment/migrations/0006_auto_20170906_1635.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0005_auto_20170906_1259")] operations = [ diff --git a/timed/employment/migrations/0007_auto_20170911_0959.py b/timed/employment/migrations/0007_auto_20170911_0959.py index 01d39df6a..5739b4a7a 100644 --- a/timed/employment/migrations/0007_auto_20170911_0959.py +++ b/timed/employment/migrations/0007_auto_20170911_0959.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0006_auto_20170906_1635")] operations = [ diff --git a/timed/employment/migrations/0008_auto_20171013_1041.py b/timed/employment/migrations/0008_auto_20171013_1041.py index 3867abcc3..453045186 100644 --- a/timed/employment/migrations/0008_auto_20171013_1041.py +++ b/timed/employment/migrations/0008_auto_20171013_1041.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0007_auto_20170911_0959")] operations = [ diff --git a/timed/employment/migrations/0009_delete_userabsencetype.py b/timed/employment/migrations/0009_delete_userabsencetype.py index 9714db9fa..1efbe7178 100644 --- a/timed/employment/migrations/0009_delete_userabsencetype.py +++ b/timed/employment/migrations/0009_delete_userabsencetype.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0008_auto_20171013_1041")] operations = [migrations.DeleteModel(name="UserAbsenceType")] diff --git a/timed/employment/migrations/0010_overtimecredit_comment.py b/timed/employment/migrations/0010_overtimecredit_comment.py index 144d152b3..7c945e661 100644 --- a/timed/employment/migrations/0010_overtimecredit_comment.py +++ b/timed/employment/migrations/0010_overtimecredit_comment.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0009_delete_userabsencetype")] operations = [ diff --git a/timed/employment/migrations/0011_auto_20171101_1227.py b/timed/employment/migrations/0011_auto_20171101_1227.py index dd13d03b4..7abff86be 100644 --- a/timed/employment/migrations/0011_auto_20171101_1227.py +++ b/timed/employment/migrations/0011_auto_20171101_1227.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0010_overtimecredit_comment")] operations = [ diff --git a/timed/employment/migrations/0012_auto_20181026_1528.py b/timed/employment/migrations/0012_auto_20181026_1528.py index e4bb357da..7428cda1f 100644 --- a/timed/employment/migrations/0012_auto_20181026_1528.py +++ b/timed/employment/migrations/0012_auto_20181026_1528.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("employment", "0011_auto_20171101_1227")] operations = [ diff --git a/timed/employment/migrations/0013_auto_20210302_1136.py b/timed/employment/migrations/0013_auto_20210302_1136.py index e8a716cf7..0a6757a8e 100644 --- a/timed/employment/migrations/0013_auto_20210302_1136.py +++ b/timed/employment/migrations/0013_auto_20210302_1136.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("employment", "0012_auto_20181026_1528"), ] diff --git a/timed/employment/migrations/0014_employment_is_external.py b/timed/employment/migrations/0014_employment_is_external.py index 05a8165d1..307f569f6 100644 --- a/timed/employment/migrations/0014_employment_is_external.py +++ b/timed/employment/migrations/0014_employment_is_external.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("employment", "0013_auto_20210302_1136"), ] diff --git a/timed/employment/migrations/0015_user_is_accountant.py b/timed/employment/migrations/0015_user_is_accountant.py index 7b5f17cbf..71b551f13 100644 --- a/timed/employment/migrations/0015_user_is_accountant.py +++ b/timed/employment/migrations/0015_user_is_accountant.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("employment", "0014_employment_is_external"), ] diff --git a/timed/employment/serializers.py b/timed/employment/serializers.py index 3f0898613..0a4490a01 100644 --- a/timed/employment/serializers.py +++ b/timed/employment/serializers.py @@ -20,7 +20,6 @@ class UserSerializer(ModelSerializer): - included_serializers = { "supervisors": "timed.employment.serializers.UserSerializer", "supervisees": "timed.employment.serializers.UserSerializer", diff --git a/timed/employment/tests/test_worktime_balance.py b/timed/employment/tests/test_worktime_balance.py index 72d5076b4..4db21dde3 100644 --- a/timed/employment/tests/test_worktime_balance.py +++ b/timed/employment/tests/test_worktime_balance.py @@ -177,7 +177,6 @@ def test_worktime_balance_list_filter_user(auth_client): def test_worktime_balance_list_last_reported_date_no_reports( auth_client, django_assert_num_queries ): - url = reverse("worktime-balance-list") with django_assert_num_queries(1): @@ -193,7 +192,6 @@ def test_worktime_balance_list_last_reported_date_no_reports( def test_worktime_balance_list_last_reported_date( auth_client, django_assert_num_queries ): - EmploymentFactory.create( user=auth_client.user, start_date=date(2017, 2, 1), diff --git a/timed/notifications/migrations/0001_initial.py b/timed/notifications/migrations/0001_initial.py index 4e3001cab..3ee6417e1 100644 --- a/timed/notifications/migrations/0001_initial.py +++ b/timed/notifications/migrations/0001_initial.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/timed/notifications/migrations/0002_alter_notification_notification_type.py b/timed/notifications/migrations/0002_alter_notification_notification_type.py index 159576b7e..e93e5f0a8 100644 --- a/timed/notifications/migrations/0002_alter_notification_notification_type.py +++ b/timed/notifications/migrations/0002_alter_notification_notification_type.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("notifications", "0001_initial"), ] diff --git a/timed/projects/migrations/0001_initial.py b/timed/projects/migrations/0001_initial.py index d0ec34d15..08453f60e 100644 --- a/timed/projects/migrations/0001_initial.py +++ b/timed/projects/migrations/0001_initial.py @@ -8,7 +8,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] diff --git a/timed/projects/migrations/0002_auto_20170823_1045.py b/timed/projects/migrations/0002_auto_20170823_1045.py index 6aff4650e..4e2852516 100644 --- a/timed/projects/migrations/0002_auto_20170823_1045.py +++ b/timed/projects/migrations/0002_auto_20170823_1045.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("projects", "0001_initial")] operations = [ diff --git a/timed/projects/migrations/0003_auto_20170831_1624.py b/timed/projects/migrations/0003_auto_20170831_1624.py index ad7c8ec4a..2f4f2d06f 100644 --- a/timed/projects/migrations/0003_auto_20170831_1624.py +++ b/timed/projects/migrations/0003_auto_20170831_1624.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("projects", "0002_auto_20170823_1045")] operations = [ diff --git a/timed/projects/migrations/0004_auto_20170906_1045.py b/timed/projects/migrations/0004_auto_20170906_1045.py index d9580d10a..7d65e2dc2 100644 --- a/timed/projects/migrations/0004_auto_20170906_1045.py +++ b/timed/projects/migrations/0004_auto_20170906_1045.py @@ -22,7 +22,6 @@ def migrate_estimated_hours(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("projects", "0003_auto_20170831_1624")] operations = [ diff --git a/timed/projects/migrations/0005_auto_20170907_0938.py b/timed/projects/migrations/0005_auto_20170907_0938.py index a54983368..6e0543f2f 100644 --- a/timed/projects/migrations/0005_auto_20170907_0938.py +++ b/timed/projects/migrations/0005_auto_20170907_0938.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("projects", "0004_auto_20170906_1045")] operations = [ diff --git a/timed/projects/migrations/0006_auto_20171010_1423.py b/timed/projects/migrations/0006_auto_20171010_1423.py index 4ee5adb8d..be0f3e84e 100644 --- a/timed/projects/migrations/0006_auto_20171010_1423.py +++ b/timed/projects/migrations/0006_auto_20171010_1423.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("projects", "0005_auto_20170907_0938")] operations = [ diff --git a/timed/projects/migrations/0007_project_subscription_project.py b/timed/projects/migrations/0007_project_subscription_project.py index d58ecb267..5d1ccdc64 100644 --- a/timed/projects/migrations/0007_project_subscription_project.py +++ b/timed/projects/migrations/0007_project_subscription_project.py @@ -16,7 +16,6 @@ def migrate_projects(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("projects", "0006_auto_20171010_1423"), ("subscription", "0003_auto_20170907_1151"), diff --git a/timed/projects/migrations/0008_auto_20190220_1133.py b/timed/projects/migrations/0008_auto_20190220_1133.py index 061d445f8..1017bccb6 100644 --- a/timed/projects/migrations/0008_auto_20190220_1133.py +++ b/timed/projects/migrations/0008_auto_20190220_1133.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("projects", "0007_project_subscription_project")] operations = [ diff --git a/timed/projects/migrations/0009_auto_20201201_1412.py b/timed/projects/migrations/0009_auto_20201201_1412.py index 274b72849..522e00f38 100644 --- a/timed/projects/migrations/0009_auto_20201201_1412.py +++ b/timed/projects/migrations/0009_auto_20201201_1412.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("projects", "0008_auto_20190220_1133"), ] diff --git a/timed/projects/migrations/0010_project_billed.py b/timed/projects/migrations/0010_project_billed.py index 50f17c30f..1448d63fc 100644 --- a/timed/projects/migrations/0010_project_billed.py +++ b/timed/projects/migrations/0010_project_billed.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("projects", "0009_auto_20201201_1412"), ] diff --git a/timed/projects/migrations/0011_auto_20210419_1459.py b/timed/projects/migrations/0011_auto_20210419_1459.py index 662e0a6a0..1de4770a6 100644 --- a/timed/projects/migrations/0011_auto_20210419_1459.py +++ b/timed/projects/migrations/0011_auto_20210419_1459.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("projects", "0010_project_billed"), diff --git a/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py b/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py index 1e89c09cf..637ebbc54 100644 --- a/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py +++ b/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py @@ -16,7 +16,6 @@ def migrate_reviewers(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("projects", "0011_auto_20210419_1459"), ] diff --git a/timed/projects/migrations/0013_remove_project_reviewers.py b/timed/projects/migrations/0013_remove_project_reviewers.py index c080cd78a..146721523 100644 --- a/timed/projects/migrations/0013_remove_project_reviewers.py +++ b/timed/projects/migrations/0013_remove_project_reviewers.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("projects", "0012_migrate_reviewers_to_assignees"), ] diff --git a/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py b/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py index 9984de6cf..2f947a086 100644 --- a/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py +++ b/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("projects", "0013_remove_project_reviewers"), ] diff --git a/timed/projects/migrations/0015_remaining_effort_task_project.py b/timed/projects/migrations/0015_remaining_effort_task_project.py index de54af422..645e12287 100644 --- a/timed/projects/migrations/0015_remaining_effort_task_project.py +++ b/timed/projects/migrations/0015_remaining_effort_task_project.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("projects", "0014_add_is_customer_role_to_assignees"), ] diff --git a/timed/redmine/migrations/0001_initial.py b/timed/redmine/migrations/0001_initial.py index b02e59b62..b0f673a3a 100644 --- a/timed/redmine/migrations/0001_initial.py +++ b/timed/redmine/migrations/0001_initial.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [("projects", "0001_initial")] diff --git a/timed/reports/filters.py b/timed/reports/filters.py index ebe771758..113e26b2a 100644 --- a/timed/reports/filters.py +++ b/timed/reports/filters.py @@ -77,7 +77,6 @@ def filter_cost_center(self, queryset, name, value): return queryset.filter_base(filter_q).filter_aggregate(filter_q) def filter_queryset(self, queryset): - qs = super().filter_queryset(queryset) duration_ref = self._refs["reports_ref"] + "__duration" diff --git a/timed/reports/tests/test_customer_statistic.py b/timed/reports/tests/test_customer_statistic.py index ceb7dac4e..e3972cf7b 100644 --- a/timed/reports/tests/test_customer_statistic.py +++ b/timed/reports/tests/test_customer_statistic.py @@ -29,7 +29,6 @@ def test_customer_statistic_list( status_code, django_assert_num_queries, ): - user = auth_client.user assignee, employment = setup_customer_and_employment_status( @@ -135,7 +134,7 @@ def test_customer_statistic_filtered(auth_client, filter, expected_result): @pytest.mark.parametrize( "is_employed, expected, status_code", [ - (True, 4, status.HTTP_200_OK), + (True, 5, status.HTTP_200_OK), (False, 1, status.HTTP_403_FORBIDDEN), ], ) diff --git a/timed/reports/tests/test_task_statistic.py b/timed/reports/tests/test_task_statistic.py index 6832fd0fc..94918e208 100644 --- a/timed/reports/tests/test_task_statistic.py +++ b/timed/reports/tests/test_task_statistic.py @@ -101,7 +101,6 @@ def test_task_statistic_filtered( filter, expected_result, ): - user = auth_client.user setup_customer_and_employment_status( user=user, diff --git a/timed/reports/views.py b/timed/reports/views.py index 52412dd73..d293deb2a 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -81,7 +81,6 @@ def __init__(self, catch_prefixes, *args, base_qs=None, agg_filters=None, **kwar self._catch_prefixes = catch_prefixes def filter(self, *args, **kwargs): - if args: # pragma: no cover # This is a check against programming errors, no need to test raise RuntimeError( diff --git a/timed/subscription/migrations/0001_initial.py b/timed/subscription/migrations/0001_initial.py index 57528534c..acbde0e22 100644 --- a/timed/subscription/migrations/0001_initial.py +++ b/timed/subscription/migrations/0001_initial.py @@ -11,7 +11,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/timed/subscription/migrations/0002_auto_20170808_1729.py b/timed/subscription/migrations/0002_auto_20170808_1729.py index 150f79c21..fb6afe0c9 100644 --- a/timed/subscription/migrations/0002_auto_20170808_1729.py +++ b/timed/subscription/migrations/0002_auto_20170808_1729.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("subscription", "0001_initial")] operations = [ diff --git a/timed/subscription/migrations/0003_auto_20170907_1151.py b/timed/subscription/migrations/0003_auto_20170907_1151.py index 2d1b0aa44..11b35df35 100644 --- a/timed/subscription/migrations/0003_auto_20170907_1151.py +++ b/timed/subscription/migrations/0003_auto_20170907_1151.py @@ -31,7 +31,6 @@ def migrate_packages(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("projects", "0005_auto_20170907_0938"), ("subscription", "0002_auto_20170808_1729"), diff --git a/timed/subscription/migrations/0004_auto_20200407_2052.py b/timed/subscription/migrations/0004_auto_20200407_2052.py index eb4f63cac..a708df253 100644 --- a/timed/subscription/migrations/0004_auto_20200407_2052.py +++ b/timed/subscription/migrations/0004_auto_20200407_2052.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [("subscription", "0003_auto_20170907_1151")] operations = [ diff --git a/timed/subscription/migrations/0005_alter_package_price_currency.py b/timed/subscription/migrations/0005_alter_package_price_currency.py index 866a13a70..211ff0180 100644 --- a/timed/subscription/migrations/0005_alter_package_price_currency.py +++ b/timed/subscription/migrations/0005_alter_package_price_currency.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("subscription", "0004_auto_20200407_2052"), ] diff --git a/timed/subscription/migrations/0006_alter_package_price_currency.py b/timed/subscription/migrations/0006_alter_package_price_currency.py index b1b8357f4..f0aa466c8 100644 --- a/timed/subscription/migrations/0006_alter_package_price_currency.py +++ b/timed/subscription/migrations/0006_alter_package_price_currency.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("subscription", "0005_alter_package_price_currency"), ] diff --git a/timed/subscription/serializers.py b/timed/subscription/serializers.py index 30a443672..1138e8cb7 100644 --- a/timed/subscription/serializers.py +++ b/timed/subscription/serializers.py @@ -76,7 +76,6 @@ class Meta: class OrderSerializer(ModelSerializer): - included_serializers = { "project": ("timed.subscription.serializers" ".SubscriptionProjectSerializer") } diff --git a/timed/tracking/migrations/0001_initial.py b/timed/tracking/migrations/0001_initial.py index 0fd4a2ca7..2c312f272 100644 --- a/timed/tracking/migrations/0001_initial.py +++ b/timed/tracking/migrations/0001_initial.py @@ -9,7 +9,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/timed/tracking/migrations/0002_auto_20170912_1346.py b/timed/tracking/migrations/0002_auto_20170912_1346.py index feeab5e80..6a8784325 100644 --- a/timed/tracking/migrations/0002_auto_20170912_1346.py +++ b/timed/tracking/migrations/0002_auto_20170912_1346.py @@ -36,7 +36,6 @@ def delete_invalid_blocks(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("tracking", "0001_initial")] operations = [ diff --git a/timed/tracking/migrations/0003_auto_20170912_1347.py b/timed/tracking/migrations/0003_auto_20170912_1347.py index dd9ee03f5..e71194ae9 100644 --- a/timed/tracking/migrations/0003_auto_20170912_1347.py +++ b/timed/tracking/migrations/0003_auto_20170912_1347.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0002_auto_20170912_1346")] operations = [ diff --git a/timed/tracking/migrations/0004_auto_20171005_1057.py b/timed/tracking/migrations/0004_auto_20171005_1057.py index 281016c39..4075742c3 100644 --- a/timed/tracking/migrations/0004_auto_20171005_1057.py +++ b/timed/tracking/migrations/0004_auto_20171005_1057.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0003_auto_20170912_1347")] operations = [ diff --git a/timed/tracking/migrations/0005_remove_absence_duration.py b/timed/tracking/migrations/0005_remove_absence_duration.py index d2999e463..087c5378d 100644 --- a/timed/tracking/migrations/0005_remove_absence_duration.py +++ b/timed/tracking/migrations/0005_remove_absence_duration.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0004_auto_20171005_1057")] operations = [migrations.RemoveField(model_name="absence", name="duration")] diff --git a/timed/tracking/migrations/0006_add_activity_time.py b/timed/tracking/migrations/0006_add_activity_time.py index 06dcb5953..528925a46 100644 --- a/timed/tracking/migrations/0006_add_activity_time.py +++ b/timed/tracking/migrations/0006_add_activity_time.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0005_remove_absence_duration")] operations = [ diff --git a/timed/tracking/migrations/0007_migrate_activity_blocks.py b/timed/tracking/migrations/0007_migrate_activity_blocks.py index 604a41ff3..c13a0a968 100644 --- a/timed/tracking/migrations/0007_migrate_activity_blocks.py +++ b/timed/tracking/migrations/0007_migrate_activity_blocks.py @@ -21,7 +21,6 @@ def migrate_blocks(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("tracking", "0006_add_activity_time")] operations = [migrations.RunPython(migrate_blocks)] diff --git a/timed/tracking/migrations/0008_delete_activity_blocks.py b/timed/tracking/migrations/0008_delete_activity_blocks.py index eee570960..66e7c751d 100644 --- a/timed/tracking/migrations/0008_delete_activity_blocks.py +++ b/timed/tracking/migrations/0008_delete_activity_blocks.py @@ -2,7 +2,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0007_migrate_activity_blocks")] operations = [ diff --git a/timed/tracking/migrations/0009_remove_report_activity.py b/timed/tracking/migrations/0009_remove_report_activity.py index 866ed6636..de4610720 100644 --- a/timed/tracking/migrations/0009_remove_report_activity.py +++ b/timed/tracking/migrations/0009_remove_report_activity.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0008_delete_activity_blocks")] operations = [migrations.RemoveField(model_name="report", name="activity")] diff --git a/timed/tracking/migrations/0010_auto_20180904_0818.py b/timed/tracking/migrations/0010_auto_20180904_0818.py index 6f75246cf..1f95c8f6e 100644 --- a/timed/tracking/migrations/0010_auto_20180904_0818.py +++ b/timed/tracking/migrations/0010_auto_20180904_0818.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0009_remove_report_activity")] operations = [ diff --git a/timed/tracking/migrations/0011_auto_20181026_1528.py b/timed/tracking/migrations/0011_auto_20181026_1528.py index d257fb3ac..7437b431b 100644 --- a/timed/tracking/migrations/0011_auto_20181026_1528.py +++ b/timed/tracking/migrations/0011_auto_20181026_1528.py @@ -8,7 +8,6 @@ class Migration(migrations.Migration): - dependencies = [("tracking", "0010_auto_20180904_0818")] operations = [ diff --git a/timed/tracking/migrations/0012_migrate_report_review_false.py b/timed/tracking/migrations/0012_migrate_report_review_false.py index 98f385699..038fb0f1a 100644 --- a/timed/tracking/migrations/0012_migrate_report_review_false.py +++ b/timed/tracking/migrations/0012_migrate_report_review_false.py @@ -11,7 +11,6 @@ def migrate_report_review(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("tracking", "0011_auto_20181026_1528")] operations = [migrations.RunPython(migrate_report_review)] diff --git a/timed/tracking/migrations/0013_report_billed.py b/timed/tracking/migrations/0013_report_billed.py index 0216ab4ef..9ef23117d 100644 --- a/timed/tracking/migrations/0013_report_billed.py +++ b/timed/tracking/migrations/0013_report_billed.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("tracking", "0012_migrate_report_review_false"), ] diff --git a/timed/tracking/migrations/0014_rename_type_absence_absence_type.py b/timed/tracking/migrations/0014_rename_type_absence_absence_type.py index 00b9efc43..54d65e339 100644 --- a/timed/tracking/migrations/0014_rename_type_absence_absence_type.py +++ b/timed/tracking/migrations/0014_rename_type_absence_absence_type.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("tracking", "0013_report_billed"), ] diff --git a/timed/tracking/migrations/0015_report_rejected.py b/timed/tracking/migrations/0015_report_rejected.py index 91fc30d51..6cd8eab36 100644 --- a/timed/tracking/migrations/0015_report_rejected.py +++ b/timed/tracking/migrations/0015_report_rejected.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("tracking", "0014_rename_type_absence_absence_type"), ] diff --git a/timed/tracking/migrations/0016_report_remaining_effort.py b/timed/tracking/migrations/0016_report_remaining_effort.py index fbb2f801d..01093e377 100644 --- a/timed/tracking/migrations/0016_report_remaining_effort.py +++ b/timed/tracking/migrations/0016_report_remaining_effort.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("tracking", "0015_report_rejected"), ] diff --git a/timed/tracking/migrations/0017_alter_report_remaining_effort.py b/timed/tracking/migrations/0017_alter_report_remaining_effort.py index 8fc7ae418..6339038da 100644 --- a/timed/tracking/migrations/0017_alter_report_remaining_effort.py +++ b/timed/tracking/migrations/0017_alter_report_remaining_effort.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("tracking", "0016_report_remaining_effort"), ] From d12d884e1e571d36726df66179bda22af4c3a1d1 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 29 Nov 2023 10:45:54 +0100 Subject: [PATCH 978/980] chore: move frontend files into frontend subdirectory This is part of merging timed-backend and timed-frontend into one repository. --- .bowerrc => frontend/.bowerrc | 0 .dockerignore => frontend/.dockerignore | 0 .editorconfig => frontend/.editorconfig | 0 .ember-cli => frontend/.ember-cli | 0 .eslintignore => frontend/.eslintignore | 0 .eslintrc.js => frontend/.eslintrc.js | 0 {.github => frontend/.github}/dependabot.yml | 0 .../.github}/workflows/release-image.yml | 0 .../.github}/workflows/release-npm.yml | 0 {.github => frontend/.github}/workflows/test.yml | 0 .gitignore => frontend/.gitignore | 0 {.husky => frontend/.husky}/commit-msg | 0 {.husky => frontend/.husky}/pre-commit | 0 .npmrc => frontend/.npmrc | 0 .prettierignore => frontend/.prettierignore | 0 .../.template-lintrc-ci.js | 0 .template-lintrc.js => frontend/.template-lintrc.js | 0 .watchmanconfig => frontend/.watchmanconfig | 0 CHANGELOG.md => frontend/CHANGELOG.md | 0 Dockerfile => frontend/Dockerfile | 0 LICENSE => frontend/LICENSE | 0 README.md => frontend/README.md | 0 {app => frontend/app}/abilities/absence-credit.js | 0 {app => frontend/app}/abilities/overtime-credit.js | 0 {app => frontend/app}/abilities/page.js | 0 {app => frontend/app}/abilities/report.js | 0 {app => frontend/app}/abilities/user.js | 0 {app => frontend/app}/adapters/activity-block.js | 0 {app => frontend/app}/adapters/application.js | 0 {app => frontend/app}/analysis/edit/controller.js | 0 {app => frontend/app}/analysis/edit/route.js | 0 {app => frontend/app}/analysis/edit/template.hbs | 0 {app => frontend/app}/analysis/index/controller.js | 0 {app => frontend/app}/analysis/index/route.js | 0 {app => frontend/app}/analysis/index/template.hbs | 0 {app => frontend/app}/analysis/route.js | 0 {app => frontend/app}/app.js | 0 {app => frontend/app}/application/route.js | 0 {app => frontend/app}/application/template.hbs | 0 {app => frontend/app}/breakpoints.js | 0 .../app}/components/async-list/template.hbs | 0 .../app}/components/attendance-slider/component.js | 0 .../app}/components/attendance-slider/template.hbs | 0 .../app}/components/balance-donut/component.js | 0 .../app}/components/balance-donut/template.hbs | 0 .../app}/components/changed-warning/template.hbs | 0 .../components/customer-visible-icon/template.hbs | 0 .../app}/components/date-buttons/component.js | 0 .../app}/components/date-buttons/template.hbs | 0 .../app}/components/date-navigation/component.js | 0 .../app}/components/date-navigation/template.hbs | 0 .../app}/components/duration-since/component.js | 0 .../app}/components/duration-since/template.hbs | 0 .../app}/components/filter-sidebar/component.js | 0 .../components/filter-sidebar/filter/template.hbs | 0 .../components/filter-sidebar/group/component.js | 0 .../components/filter-sidebar/group/styles.scss | 0 .../components/filter-sidebar/group/template.hbs | 0 .../components/filter-sidebar/label/template.hbs | 0 .../app}/components/filter-sidebar/template.hbs | 0 .../app}/components/in-viewport/component.js | 0 .../app}/components/in-viewport/template.hbs | 0 .../app}/components/loading-icon/template.hbs | 0 .../app}/components/magic-link-btn/component.js | 0 .../app}/components/magic-link-btn/template.hbs | 0 .../app}/components/magic-link-modal/component.js | 0 .../app}/components/magic-link-modal/template.hbs | 0 .../app}/components/no-mobile-message/template.hbs | 0 .../app}/components/no-permission/template.hbs | 0 .../components/not-identical-warning/template.hbs | 0 .../components/optimized-power-select/component.js | 0 .../custom-options/customer-option.hbs | 0 .../custom-options/project-option.hbs | 0 .../custom-options/task-option.hbs | 0 .../custom-options/user-option.hbs | 0 .../custom-select/task-selection.hbs | 0 .../custom-select/user-selection.hbs | 0 .../optimized-power-select/options/component.js | 0 .../optimized-power-select/options/template.hbs | 0 .../components/optimized-power-select/template.hbs | 0 .../optimized-power-select/trigger/component.js | 0 .../optimized-power-select/trigger/template.hbs | 0 .../app}/components/page-permission/template.hbs | 0 .../app}/components/progress-tooltip/component.js | 0 .../app}/components/progress-tooltip/template.hbs | 0 .../app}/components/record-button/component.js | 0 .../app}/components/record-button/template.hbs | 0 .../components/report-review-warning/component.js | 0 .../components/report-review-warning/template.hbs | 0 .../app}/components/report-row/component.js | 0 .../app}/components/report-row/template.hbs | 0 .../app}/components/scroll-container.hbs | 0 .../app}/components/sort-header/component.js | 0 .../app}/components/sort-header/template.hbs | 0 .../app}/components/statistic-list/bar/component.js | 0 .../app}/components/statistic-list/bar/template.hbs | 0 .../components/statistic-list/column/template.hbs | 0 .../app}/components/statistic-list/component.js | 0 .../app}/components/statistic-list/template.hbs | 0 .../app}/components/sy-calendar/component.js | 0 .../app}/components/sy-calendar/styles.scss | 0 .../app}/components/sy-calendar/template.hbs | 0 .../app}/components/sy-checkbox/component.js | 0 .../app}/components/sy-checkbox/template.hbs | 0 .../app}/components/sy-checkmark/component.js | 0 .../app}/components/sy-checkmark/template.hbs | 0 .../app}/components/sy-datepicker-btn/component.js | 0 .../app}/components/sy-datepicker-btn/template.hbs | 0 .../app}/components/sy-datepicker/component.js | 0 .../app}/components/sy-datepicker/template.hbs | 0 .../components/sy-durationpicker-day/component.js | 0 .../components/sy-durationpicker-day/template.hbs | 0 .../app}/components/sy-durationpicker/component.js | 0 .../app}/components/sy-durationpicker/template.hbs | 0 .../app}/components/sy-modal-target/template.hbs | 0 .../app}/components/sy-modal/body/styles.scss | 0 .../app}/components/sy-modal/body/template.hbs | 0 .../app}/components/sy-modal/footer/template.hbs | 0 .../app}/components/sy-modal/header/template.hbs | 0 .../app}/components/sy-modal/overlay/component.js | 0 .../app}/components/sy-modal/overlay/template.hbs | 0 .../app}/components/sy-modal/template.hbs | 0 .../app}/components/sy-timepicker/component.js | 0 .../app}/components/sy-timepicker/template.hbs | 0 .../app}/components/sy-toggle/component.js | 0 .../app}/components/sy-toggle/template.hbs | 0 .../app}/components/sy-topnav/component.js | 0 .../app}/components/sy-topnav/template.hbs | 0 .../app}/components/task-selection/component.js | 0 .../app}/components/task-selection/template.hbs | 0 .../app}/components/timed-clock/component.js | 0 .../app}/components/timed-clock/template.hbs | 0 .../app}/components/tracking-bar/component.js | 0 .../app}/components/tracking-bar/template.hbs | 0 .../app}/components/user-selection/component.js | 0 .../app}/components/user-selection/template.hbs | 0 .../components/vertical-collection/component.js | 0 .../weekly-overview-benchmark/component.js | 0 .../weekly-overview-benchmark/template.hbs | 0 .../components/weekly-overview-day/component.js | 0 .../components/weekly-overview-day/template.hbs | 0 .../app}/components/weekly-overview/component.js | 0 .../app}/components/weekly-overview/template.hbs | 0 .../app}/components/welcome-modal/template.hbs | 0 .../components/worktime-balance-chart/component.js | 0 .../components/worktime-balance-chart/template.hbs | 0 {app => frontend/app}/controllers/qpcontroller.js | 0 .../app}/helpers/balance-highlight-class.js | 0 {app => frontend/app}/helpers/format-duration.js | 0 {app => frontend/app}/helpers/humanize-duration.js | 0 .../app}/helpers/parse-django-duration.js | 0 {app => frontend/app}/index.html | 0 .../app}/index/activities/controller.js | 0 .../app}/index/activities/edit/controller.js | 0 .../app}/index/activities/edit/route.js | 0 .../app}/index/activities/edit/template.hbs | 0 {app => frontend/app}/index/activities/route.js | 0 {app => frontend/app}/index/activities/template.hbs | 0 .../app}/index/attendances/controller.js | 0 {app => frontend/app}/index/attendances/route.js | 0 .../app}/index/attendances/template.hbs | 0 {app => frontend/app}/index/controller.js | 0 {app => frontend/app}/index/reports/controller.js | 0 {app => frontend/app}/index/reports/route.js | 0 {app => frontend/app}/index/reports/template.hbs | 0 {app => frontend/app}/index/route.js | 0 {app => frontend/app}/index/template.hbs | 0 {app => frontend/app}/initializers/responsive.js | 0 {app => frontend/app}/login/route.js | 0 {app => frontend/app}/login/template.hbs | 0 {app => frontend/app}/models/absence-balance.js | 0 {app => frontend/app}/models/absence-credit.js | 0 {app => frontend/app}/models/absence-type.js | 0 {app => frontend/app}/models/absence.js | 0 {app => frontend/app}/models/activity.js | 0 {app => frontend/app}/models/attendance.js | 0 {app => frontend/app}/models/billing-type.js | 0 {app => frontend/app}/models/cost-center.js | 0 {app => frontend/app}/models/customer-assignee.js | 0 {app => frontend/app}/models/customer-statistic.js | 0 {app => frontend/app}/models/customer.js | 0 {app => frontend/app}/models/employment.js | 0 {app => frontend/app}/models/location.js | 0 {app => frontend/app}/models/month-statistic.js | 0 {app => frontend/app}/models/overtime-credit.js | 0 {app => frontend/app}/models/project-assignee.js | 0 {app => frontend/app}/models/project-statistic.js | 0 {app => frontend/app}/models/project.js | 0 {app => frontend/app}/models/public-holiday.js | 0 {app => frontend/app}/models/report-intersection.js | 0 {app => frontend/app}/models/report.js | 0 {app => frontend/app}/models/task-assignee.js | 0 {app => frontend/app}/models/task-statistic.js | 0 {app => frontend/app}/models/task.js | 0 {app => frontend/app}/models/user-statistic.js | 0 {app => frontend/app}/models/user.js | 0 {app => frontend/app}/models/worktime-balance.js | 0 {app => frontend/app}/models/year-statistic.js | 0 {app => frontend/app}/no-access/route.js | 0 {app => frontend/app}/no-access/template.hbs | 0 {app => frontend/app}/notfound/route.js | 0 {app => frontend/app}/notfound/template.hbs | 0 {app => frontend/app}/projects/controller.js | 0 {app => frontend/app}/projects/route.js | 0 {app => frontend/app}/projects/template.hbs | 0 {app => frontend/app}/protected/controller.js | 0 {app => frontend/app}/protected/route.js | 0 {app => frontend/app}/protected/template.hbs | 0 {app => frontend/app}/router.js | 0 {app => frontend/app}/serializers/application.js | 0 {app => frontend/app}/serializers/attendance.js | 0 {app => frontend/app}/serializers/employment.js | 0 {app => frontend/app}/services/autostart-tour.js | 0 {app => frontend/app}/services/fetch.js | 0 {app => frontend/app}/services/metadata-fetcher.js | 0 {app => frontend/app}/services/rejected-reports.js | 0 {app => frontend/app}/services/tour.js | 0 {app => frontend/app}/services/tracking.js | 0 .../app}/services/unverified-reports.js | 0 {app => frontend/app}/sso-login/route.js | 0 {app => frontend/app}/statistics/controller.js | 0 {app => frontend/app}/statistics/route.js | 0 {app => frontend/app}/statistics/template.hbs | 0 {app => frontend/app}/styles/activities.scss | 0 {app => frontend/app}/styles/adcssy.scss | 0 {app => frontend/app}/styles/analysis.scss | 0 {app => frontend/app}/styles/app.scss | 0 {app => frontend/app}/styles/attendances.scss | 0 {app => frontend/app}/styles/badge.scss | 0 .../app}/styles/components/attendance-slider.scss | 0 .../app}/styles/components/balance-donut.scss | 0 .../app}/styles/components/date-buttons.scss | 0 .../app}/styles/components/date-navigation.scss | 0 .../styles/components/filter-sidebar--group.scss | 0 .../styles/components/filter-sidebar--label.scss | 0 .../app}/styles/components/loading-icon.scss | 0 .../app}/styles/components/magic-link-btn.scss | 0 .../app}/styles/components/nav-top.scss | 0 .../app}/styles/components/progress-tooltip.scss | 0 .../app}/styles/components/record-button.scss | 0 .../app}/styles/components/scroll-container.scss | 0 .../app}/styles/components/sort-header.scss | 0 .../app}/styles/components/statistic-list-bar.scss | 0 .../app}/styles/components/sy-calendar.scss | 0 .../app}/styles/components/sy-checkbox.scss | 0 .../app}/styles/components/sy-datepicker.scss | 0 .../styles/components/sy-durationpicker-day.scss | 0 .../app}/styles/components/sy-modal--footer.scss | 0 .../app}/styles/components/sy-modal--overlay.scss | 0 .../app}/styles/components/sy-toggle.scss | 0 .../app}/styles/components/timed-clock.scss | 0 .../app}/styles/components/tracking-bar.scss | 0 .../components/weekly-overview-benchmark.scss | 0 .../app}/styles/components/weekly-overview-day.scss | 0 .../app}/styles/components/weekly-overview.scss | 0 .../app}/styles/components/welcome-modal.scss | 0 .../app}/styles/ember-power-select-custom.scss | 0 {app => frontend/app}/styles/filter-sidebar.scss | 0 {app => frontend/app}/styles/form-list.scss | 0 {app => frontend/app}/styles/loader.scss | 0 {app => frontend/app}/styles/login.scss | 0 {app => frontend/app}/styles/projects.scss | 0 {app => frontend/app}/styles/reports.scss | 0 {app => frontend/app}/styles/statistics.scss | 0 {app => frontend/app}/styles/toolbar.scss | 0 {app => frontend/app}/styles/tour.scss | 0 {app => frontend/app}/styles/users-navigation.scss | 0 {app => frontend/app}/styles/users.scss | 0 {app => frontend/app}/styles/variables.scss | 0 {app => frontend/app}/tours/index.js | 0 {app => frontend/app}/tours/index/activities.js | 0 {app => frontend/app}/tours/index/attendances.js | 0 {app => frontend/app}/tours/index/reports.js | 0 {app => frontend/app}/transforms/django-date.js | 0 {app => frontend/app}/transforms/django-datetime.js | 0 {app => frontend/app}/transforms/django-duration.js | 0 {app => frontend/app}/transforms/django-time.js | 0 {app => frontend/app}/transforms/django-workdays.js | 0 {app => frontend/app}/transforms/moment.js | 0 {app => frontend/app}/users/edit/controller.js | 0 .../edit/credits/absence-credits/edit/controller.js | 0 .../edit/credits/absence-credits/edit/route.js | 0 .../edit/credits/absence-credits/edit/template.hbs | 0 .../users/edit/credits/absence-credits/new/route.js | 0 .../app}/users/edit/credits/index/controller.js | 0 .../app}/users/edit/credits/index/route.js | 0 .../app}/users/edit/credits/index/template.hbs | 0 .../credits/overtime-credits/edit/controller.js | 0 .../edit/credits/overtime-credits/edit/route.js | 0 .../edit/credits/overtime-credits/edit/template.hbs | 0 .../edit/credits/overtime-credits/new/route.js | 0 {app => frontend/app}/users/edit/credits/route.js | 0 .../app}/users/edit/credits/template.hbs | 0 .../app}/users/edit/index/controller.js | 0 {app => frontend/app}/users/edit/index/route.js | 0 {app => frontend/app}/users/edit/index/template.hbs | 0 .../app}/users/edit/responsibilities/controller.js | 0 .../app}/users/edit/responsibilities/route.js | 0 .../app}/users/edit/responsibilities/template.hbs | 0 {app => frontend/app}/users/edit/route.js | 0 {app => frontend/app}/users/edit/template.hbs | 0 {app => frontend/app}/users/index/controller.js | 0 {app => frontend/app}/users/index/route.js | 0 {app => frontend/app}/users/index/template.hbs | 0 {app => frontend/app}/users/route.js | 0 {app => frontend/app}/users/template.hbs | 0 {app => frontend/app}/utils/format-duration.js | 0 {app => frontend/app}/utils/humanize-duration.js | 0 .../app}/utils/parse-django-duration.js | 0 {app => frontend/app}/utils/query-params.js | 0 {app => frontend/app}/utils/serialize-moment.js | 0 {app => frontend/app}/utils/url.js | 0 {app => frontend/app}/validations/absence-credit.js | 0 {app => frontend/app}/validations/absence.js | 0 {app => frontend/app}/validations/activity.js | 0 {app => frontend/app}/validations/attendance.js | 0 {app => frontend/app}/validations/intersection.js | 0 .../app}/validations/multiple-absence.js | 0 .../app}/validations/overtime-credit.js | 0 {app => frontend/app}/validations/project.js | 0 {app => frontend/app}/validations/report.js | 0 {app => frontend/app}/validations/task.js | 0 .../app}/validators/intersection-task.js | 0 {app => frontend/app}/validators/moment.js | 0 .../app}/validators/null-or-not-blank.js | 0 {config => frontend/config}/coverage.js | 0 {config => frontend/config}/dependency-lint.js | 0 {config => frontend/config}/deprecation-workflow.js | 0 {config => frontend/config}/ember-cli-update.json | 0 {config => frontend/config}/environment.js | 0 {config => frontend/config}/icons.js | 0 {config => frontend/config}/optional-features.json | 0 {config => frontend/config}/targets.js | 0 {contrib => frontend/contrib}/nginx.conf | 0 docker-compose.yml => frontend/docker-compose.yml | 0 .../docker-entrypoint.sh | 0 ember-cli-build.js => frontend/ember-cli-build.js | 0 {mirage => frontend/mirage}/config.js | 0 .../mirage}/factories/absence-balance.js | 0 .../mirage}/factories/absence-credit.js | 0 .../mirage}/factories/absence-type.js | 0 {mirage => frontend/mirage}/factories/absence.js | 0 {mirage => frontend/mirage}/factories/activity.js | 0 {mirage => frontend/mirage}/factories/attendance.js | 0 .../mirage}/factories/billing-type.js | 0 .../mirage}/factories/cost-center.js | 0 .../mirage}/factories/customer-statistic.js | 0 {mirage => frontend/mirage}/factories/customer.js | 0 {mirage => frontend/mirage}/factories/employment.js | 0 {mirage => frontend/mirage}/factories/location.js | 0 .../mirage}/factories/month-statistic.js | 0 .../mirage}/factories/overtime-credit.js | 0 .../mirage}/factories/project-assignee.js | 0 .../mirage}/factories/project-statistic.js | 0 {mirage => frontend/mirage}/factories/project.js | 0 .../mirage}/factories/public-holiday.js | 0 .../mirage}/factories/report-intersection.js | 0 {mirage => frontend/mirage}/factories/report.js | 0 .../mirage}/factories/task-statistic.js | 0 {mirage => frontend/mirage}/factories/task.js | 0 .../mirage}/factories/user-statistic.js | 0 {mirage => frontend/mirage}/factories/user.js | 0 .../mirage}/factories/worktime-balance.js | 0 .../mirage}/factories/year-statistic.js | 0 .../mirage}/fixtures/absence-types.js | 0 {mirage => frontend/mirage}/helpers/duration.js | 0 {mirage => frontend/mirage}/scenarios/default.js | 0 .../mirage}/serializers/application.js | 0 package.json => frontend/package.json | 0 pnpm-lock.yaml => frontend/pnpm-lock.yaml | 0 .../public}/assets/favicon-16x16.png | Bin .../public}/assets/favicon-32x32.png | Bin {public => frontend/public}/assets/favicon.ico | Bin {public => frontend/public}/assets/logo.png | Bin {public => frontend/public}/assets/logo.svg | 0 {public => frontend/public}/assets/logo_text.png | Bin {public => frontend/public}/crossdomain.xml | 0 {public => frontend/public}/robots.txt | 0 renovate.json => frontend/renovate.json | 0 testem.js => frontend/testem.js | 0 {tests => frontend/tests}/.eslintrc.js | 0 .../tests}/acceptance/analysis-edit-test.js | 0 .../tests}/acceptance/analysis-test.js | 0 {tests => frontend/tests}/acceptance/auth-test.js | 0 .../tests}/acceptance/external-employee-test.js | 0 .../tests}/acceptance/index-activities-edit-test.js | 0 .../tests}/acceptance/index-activities-test.js | 0 .../tests}/acceptance/index-attendances-test.js | 0 .../tests}/acceptance/index-reports-test.js | 0 {tests => frontend/tests}/acceptance/index-test.js | 0 .../tests}/acceptance/magic-link-test.js | 0 .../tests}/acceptance/notfound-test.js | 0 .../tests}/acceptance/project-test.js | 0 .../tests}/acceptance/statistics-test.js | 0 {tests => frontend/tests}/acceptance/tour-test.js | 0 .../users-edit-credits-absence-credit-test.js | 0 .../users-edit-credits-overtime-credit-test.js | 0 .../tests}/acceptance/users-edit-credits-test.js | 0 .../acceptance/users-edit-responsibilities-test.js | 0 .../tests}/acceptance/users-edit-test.js | 0 {tests => frontend/tests}/acceptance/users-test.js | 0 {tests => frontend/tests}/helpers/index.js | 0 {tests => frontend/tests}/helpers/responsive.js | 0 {tests => frontend/tests}/helpers/session-mock.js | 0 {tests => frontend/tests}/helpers/task-select.js | 0 {tests => frontend/tests}/helpers/tracking-mock.js | 0 {tests => frontend/tests}/helpers/user-select.js | 0 {tests => frontend/tests}/index.html | 0 .../components/async-list/component-test.js | 0 .../components/attendance-slider/component-test.js | 0 .../components/balance-donut/component-test.js | 0 .../components/changed-warning/component-test.js | 0 .../customer-visible-icon/component-test.js | 0 .../components/date-buttons/component-test.js | 0 .../components/date-navigation/component-test.js | 0 .../components/duration-since/component-test.js | 0 .../components/filter-sidebar/component-test.js | 0 .../filter-sidebar/filter/component-test.js | 0 .../filter-sidebar/group/component-test.js | 0 .../filter-sidebar/label/component-test.js | 0 .../components/in-viewport/component-test.js | 0 .../components/loading-icon/component-test.js | 0 .../components/no-mobile-message/component-test.js | 0 .../components/no-permission/component-test.js | 0 .../not-identical-warning/component-test.js | 0 .../optimized-power-select/component-test.js | 0 .../components/progress-tooltip/component-test.js | 0 .../components/record-button/component-test.js | 0 .../report-review-warning/component-test.js | 0 .../components/report-row/component-test.js | 0 .../components/sort-header/component-test.js | 0 .../components/statistic-list/bar/component-test.js | 0 .../statistic-list/column/component-test.js | 0 .../components/statistic-list/component-test.js | 0 .../components/sy-calendar/component-test.js | 0 .../components/sy-checkbox/component-test.js | 0 .../components/sy-checkmark/component-test.js | 0 .../components/sy-datepicker-btn/component-test.js | 0 .../components/sy-datepicker/component-test.js | 0 .../sy-durationpicker-day/component-test.js | 0 .../components/sy-durationpicker/component-test.js | 0 .../components/sy-modal-target/component-test.js | 0 .../components/sy-modal/body/component-test.js | 0 .../components/sy-modal/component-test.js | 0 .../components/sy-modal/footer/component-test.js | 0 .../components/sy-modal/header/component-test.js | 0 .../components/sy-modal/overlay/component-test.js | 0 .../components/sy-timepicker/component-test.js | 0 .../components/sy-toggle/component-test.js | 0 .../components/sy-topnav/component-test.js | 0 .../components/task-selection/component-test.js | 0 .../components/timed-clock/component-test.js | 0 .../components/tracking-bar/component-test.js | 0 .../components/user-selection/component-test.js | 0 .../weekly-overview-benchmark/component-test.js | 0 .../weekly-overview-day/component-test.js | 0 .../components/weekly-overview/component-test.js | 0 .../components/welcome-modal/component-test.js | 0 .../worktime-balance-chart/component-test.js | 0 {tests => frontend/tests}/test-helper.js | 0 .../tests}/unit/abilities/report-test.js | 0 .../tests}/unit/analysis/edit/controller-test.js | 0 .../tests}/unit/analysis/edit/route-test.js | 0 .../tests}/unit/analysis/index/controller-test.js | 0 .../tests}/unit/analysis/index/route-test.js | 0 .../tests}/unit/analysis/route-test.js | 0 .../controllers/qpcontroller/controller-test.js | 0 .../unit/helpers/balance-highlight-class-test.js | 0 .../tests}/unit/helpers/format-duration-test.js | 0 .../tests}/unit/helpers/humanize-duration-test.js | 0 .../unit/helpers/parse-django-duration-test.js | 0 .../tests}/unit/index/activities/controller-test.js | 0 .../unit/index/activities/edit/controller-test.js | 0 .../tests}/unit/index/activities/edit/route-test.js | 0 .../tests}/unit/index/activities/route-test.js | 0 .../unit/index/attendances/controller-test.js | 0 .../tests}/unit/index/attendances/route-test.js | 0 .../tests}/unit/index/controller-test.js | 0 .../tests}/unit/index/reports/controller-test.js | 0 .../tests}/unit/index/reports/route-test.js | 0 {tests => frontend/tests}/unit/index/route-test.js | 0 {tests => frontend/tests}/unit/login/route-test.js | 0 .../tests}/unit/models/absence-balance-test.js | 0 .../tests}/unit/models/activity-test.js | 0 .../tests}/unit/models/attendance-test.js | 0 .../tests}/unit/models/billing-type-test.js | 0 .../tests}/unit/models/cost-center-test.js | 0 .../tests}/unit/models/customer-statistic-test.js | 0 .../tests}/unit/models/customer-test.js | 0 .../tests}/unit/models/employment-test.js | 0 .../tests}/unit/models/location-test.js | 0 .../tests}/unit/models/month-statistic-test.js | 0 .../tests}/unit/models/overtime-credit-test.js | 0 .../tests}/unit/models/project-statistic-test.js | 0 .../tests}/unit/models/project-test.js | 0 .../tests}/unit/models/public-holiday-test.js | 0 .../tests}/unit/models/report-intersection-test.js | 0 .../tests}/unit/models/report-test.js | 0 .../tests}/unit/models/task-statistic-test.js | 0 {tests => frontend/tests}/unit/models/task-test.js | 0 .../tests}/unit/models/user-statistic-test.js | 0 {tests => frontend/tests}/unit/models/user-test.js | 0 .../tests}/unit/models/worktime-balance-test.js | 0 .../tests}/unit/models/year-statistic-test.js | 0 .../tests}/unit/no-access/route-test.js | 0 .../tests}/unit/notfound/route-test.js | 0 .../tests}/unit/projects/controller-test.js | 0 .../tests}/unit/projects/route-test.js | 0 .../tests}/unit/protected/controller-test.js | 0 .../tests}/unit/protected/route-test.js | 0 .../tests}/unit/serializers/attendance-test.js | 0 .../tests}/unit/serializers/employment-test.js | 0 .../tests}/unit/services/autostart-tour-test.js | 0 .../tests}/unit/services/fetch-test.js | 0 .../tests}/unit/services/metadata-fetcher-test.js | 0 .../tests}/unit/services/rejected-reports-test.js | 0 .../tests}/unit/services/tracking-test.js | 0 .../tests}/unit/services/unverified-reports-test.js | 0 .../tests}/unit/sso-login/route-test.js | 0 .../tests}/unit/statistics/controller-test.js | 0 .../tests}/unit/statistics/route-test.js | 0 .../tests}/unit/transforms/django-date-test.js | 0 .../tests}/unit/transforms/django-datetime-test.js | 0 .../tests}/unit/transforms/django-duration-test.js | 0 .../tests}/unit/transforms/django-time-test.js | 0 .../tests}/unit/transforms/django-workdays-test.js | 0 .../tests}/unit/users/edit/controller-test.js | 0 .../credits/absence-credits/edit/controller-test.js | 0 .../edit/credits/absence-credits/edit/route-test.js | 0 .../edit/credits/absence-credits/new/route-test.js | 0 .../users/edit/credits/index/controller-test.js | 0 .../unit/users/edit/credits/index/route-test.js | 0 .../overtime-credits/edit/controller-test.js | 0 .../credits/overtime-credits/edit/route-test.js | 0 .../edit/credits/overtime-credits/new/route-test.js | 0 .../tests}/unit/users/edit/credits/route-test.js | 0 .../tests}/unit/users/edit/index/controller-test.js | 0 .../tests}/unit/users/edit/index/route-test.js | 0 .../users/edit/responsibilities/controller-test.js | 0 .../unit/users/edit/responsibilities/route-test.js | 0 .../tests}/unit/users/edit/route-test.js | 0 .../tests}/unit/users/index/controller-test.js | 0 .../tests}/unit/users/index/route-test.js | 0 {tests => frontend/tests}/unit/users/route-test.js | 0 .../tests}/unit/utils/format-duration-test.js | 0 .../tests}/unit/utils/humanize-duration-test.js | 0 .../tests}/unit/utils/parse-django-duration-test.js | 0 .../tests}/unit/utils/query-params-test.js | 0 {tests => frontend/tests}/unit/utils/url-test.js | 0 .../tests}/unit/validators/moment-test.js | 0 .../unit/validators/null-or-not-blank-test.js | 0 {vendor => frontend/vendor}/.gitkeep | 0 552 files changed, 0 insertions(+), 0 deletions(-) rename .bowerrc => frontend/.bowerrc (100%) rename .dockerignore => frontend/.dockerignore (100%) rename .editorconfig => frontend/.editorconfig (100%) rename .ember-cli => frontend/.ember-cli (100%) rename .eslintignore => frontend/.eslintignore (100%) rename .eslintrc.js => frontend/.eslintrc.js (100%) rename {.github => frontend/.github}/dependabot.yml (100%) rename {.github => frontend/.github}/workflows/release-image.yml (100%) rename {.github => frontend/.github}/workflows/release-npm.yml (100%) rename {.github => frontend/.github}/workflows/test.yml (100%) rename .gitignore => frontend/.gitignore (100%) rename {.husky => frontend/.husky}/commit-msg (100%) rename {.husky => frontend/.husky}/pre-commit (100%) rename .npmrc => frontend/.npmrc (100%) rename .prettierignore => frontend/.prettierignore (100%) rename .template-lintrc-ci.js => frontend/.template-lintrc-ci.js (100%) rename .template-lintrc.js => frontend/.template-lintrc.js (100%) rename .watchmanconfig => frontend/.watchmanconfig (100%) rename CHANGELOG.md => frontend/CHANGELOG.md (100%) rename Dockerfile => frontend/Dockerfile (100%) rename LICENSE => frontend/LICENSE (100%) rename README.md => frontend/README.md (100%) rename {app => frontend/app}/abilities/absence-credit.js (100%) rename {app => frontend/app}/abilities/overtime-credit.js (100%) rename {app => frontend/app}/abilities/page.js (100%) rename {app => frontend/app}/abilities/report.js (100%) rename {app => frontend/app}/abilities/user.js (100%) rename {app => frontend/app}/adapters/activity-block.js (100%) rename {app => frontend/app}/adapters/application.js (100%) rename {app => frontend/app}/analysis/edit/controller.js (100%) rename {app => frontend/app}/analysis/edit/route.js (100%) rename {app => frontend/app}/analysis/edit/template.hbs (100%) rename {app => frontend/app}/analysis/index/controller.js (100%) rename {app => frontend/app}/analysis/index/route.js (100%) rename {app => frontend/app}/analysis/index/template.hbs (100%) rename {app => frontend/app}/analysis/route.js (100%) rename {app => frontend/app}/app.js (100%) rename {app => frontend/app}/application/route.js (100%) rename {app => frontend/app}/application/template.hbs (100%) rename {app => frontend/app}/breakpoints.js (100%) rename {app => frontend/app}/components/async-list/template.hbs (100%) rename {app => frontend/app}/components/attendance-slider/component.js (100%) rename {app => frontend/app}/components/attendance-slider/template.hbs (100%) rename {app => frontend/app}/components/balance-donut/component.js (100%) rename {app => frontend/app}/components/balance-donut/template.hbs (100%) rename {app => frontend/app}/components/changed-warning/template.hbs (100%) rename {app => frontend/app}/components/customer-visible-icon/template.hbs (100%) rename {app => frontend/app}/components/date-buttons/component.js (100%) rename {app => frontend/app}/components/date-buttons/template.hbs (100%) rename {app => frontend/app}/components/date-navigation/component.js (100%) rename {app => frontend/app}/components/date-navigation/template.hbs (100%) rename {app => frontend/app}/components/duration-since/component.js (100%) rename {app => frontend/app}/components/duration-since/template.hbs (100%) rename {app => frontend/app}/components/filter-sidebar/component.js (100%) rename {app => frontend/app}/components/filter-sidebar/filter/template.hbs (100%) rename {app => frontend/app}/components/filter-sidebar/group/component.js (100%) rename {app => frontend/app}/components/filter-sidebar/group/styles.scss (100%) rename {app => frontend/app}/components/filter-sidebar/group/template.hbs (100%) rename {app => frontend/app}/components/filter-sidebar/label/template.hbs (100%) rename {app => frontend/app}/components/filter-sidebar/template.hbs (100%) rename {app => frontend/app}/components/in-viewport/component.js (100%) rename {app => frontend/app}/components/in-viewport/template.hbs (100%) rename {app => frontend/app}/components/loading-icon/template.hbs (100%) rename {app => frontend/app}/components/magic-link-btn/component.js (100%) rename {app => frontend/app}/components/magic-link-btn/template.hbs (100%) rename {app => frontend/app}/components/magic-link-modal/component.js (100%) rename {app => frontend/app}/components/magic-link-modal/template.hbs (100%) rename {app => frontend/app}/components/no-mobile-message/template.hbs (100%) rename {app => frontend/app}/components/no-permission/template.hbs (100%) rename {app => frontend/app}/components/not-identical-warning/template.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/component.js (100%) rename {app => frontend/app}/components/optimized-power-select/custom-options/customer-option.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/custom-options/project-option.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/custom-options/task-option.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/custom-options/user-option.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/custom-select/task-selection.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/custom-select/user-selection.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/options/component.js (100%) rename {app => frontend/app}/components/optimized-power-select/options/template.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/template.hbs (100%) rename {app => frontend/app}/components/optimized-power-select/trigger/component.js (100%) rename {app => frontend/app}/components/optimized-power-select/trigger/template.hbs (100%) rename {app => frontend/app}/components/page-permission/template.hbs (100%) rename {app => frontend/app}/components/progress-tooltip/component.js (100%) rename {app => frontend/app}/components/progress-tooltip/template.hbs (100%) rename {app => frontend/app}/components/record-button/component.js (100%) rename {app => frontend/app}/components/record-button/template.hbs (100%) rename {app => frontend/app}/components/report-review-warning/component.js (100%) rename {app => frontend/app}/components/report-review-warning/template.hbs (100%) rename {app => frontend/app}/components/report-row/component.js (100%) rename {app => frontend/app}/components/report-row/template.hbs (100%) rename {app => frontend/app}/components/scroll-container.hbs (100%) rename {app => frontend/app}/components/sort-header/component.js (100%) rename {app => frontend/app}/components/sort-header/template.hbs (100%) rename {app => frontend/app}/components/statistic-list/bar/component.js (100%) rename {app => frontend/app}/components/statistic-list/bar/template.hbs (100%) rename {app => frontend/app}/components/statistic-list/column/template.hbs (100%) rename {app => frontend/app}/components/statistic-list/component.js (100%) rename {app => frontend/app}/components/statistic-list/template.hbs (100%) rename {app => frontend/app}/components/sy-calendar/component.js (100%) rename {app => frontend/app}/components/sy-calendar/styles.scss (100%) rename {app => frontend/app}/components/sy-calendar/template.hbs (100%) rename {app => frontend/app}/components/sy-checkbox/component.js (100%) rename {app => frontend/app}/components/sy-checkbox/template.hbs (100%) rename {app => frontend/app}/components/sy-checkmark/component.js (100%) rename {app => frontend/app}/components/sy-checkmark/template.hbs (100%) rename {app => frontend/app}/components/sy-datepicker-btn/component.js (100%) rename {app => frontend/app}/components/sy-datepicker-btn/template.hbs (100%) rename {app => frontend/app}/components/sy-datepicker/component.js (100%) rename {app => frontend/app}/components/sy-datepicker/template.hbs (100%) rename {app => frontend/app}/components/sy-durationpicker-day/component.js (100%) rename {app => frontend/app}/components/sy-durationpicker-day/template.hbs (100%) rename {app => frontend/app}/components/sy-durationpicker/component.js (100%) rename {app => frontend/app}/components/sy-durationpicker/template.hbs (100%) rename {app => frontend/app}/components/sy-modal-target/template.hbs (100%) rename {app => frontend/app}/components/sy-modal/body/styles.scss (100%) rename {app => frontend/app}/components/sy-modal/body/template.hbs (100%) rename {app => frontend/app}/components/sy-modal/footer/template.hbs (100%) rename {app => frontend/app}/components/sy-modal/header/template.hbs (100%) rename {app => frontend/app}/components/sy-modal/overlay/component.js (100%) rename {app => frontend/app}/components/sy-modal/overlay/template.hbs (100%) rename {app => frontend/app}/components/sy-modal/template.hbs (100%) rename {app => frontend/app}/components/sy-timepicker/component.js (100%) rename {app => frontend/app}/components/sy-timepicker/template.hbs (100%) rename {app => frontend/app}/components/sy-toggle/component.js (100%) rename {app => frontend/app}/components/sy-toggle/template.hbs (100%) rename {app => frontend/app}/components/sy-topnav/component.js (100%) rename {app => frontend/app}/components/sy-topnav/template.hbs (100%) rename {app => frontend/app}/components/task-selection/component.js (100%) rename {app => frontend/app}/components/task-selection/template.hbs (100%) rename {app => frontend/app}/components/timed-clock/component.js (100%) rename {app => frontend/app}/components/timed-clock/template.hbs (100%) rename {app => frontend/app}/components/tracking-bar/component.js (100%) rename {app => frontend/app}/components/tracking-bar/template.hbs (100%) rename {app => frontend/app}/components/user-selection/component.js (100%) rename {app => frontend/app}/components/user-selection/template.hbs (100%) rename {app => frontend/app}/components/vertical-collection/component.js (100%) rename {app => frontend/app}/components/weekly-overview-benchmark/component.js (100%) rename {app => frontend/app}/components/weekly-overview-benchmark/template.hbs (100%) rename {app => frontend/app}/components/weekly-overview-day/component.js (100%) rename {app => frontend/app}/components/weekly-overview-day/template.hbs (100%) rename {app => frontend/app}/components/weekly-overview/component.js (100%) rename {app => frontend/app}/components/weekly-overview/template.hbs (100%) rename {app => frontend/app}/components/welcome-modal/template.hbs (100%) rename {app => frontend/app}/components/worktime-balance-chart/component.js (100%) rename {app => frontend/app}/components/worktime-balance-chart/template.hbs (100%) rename {app => frontend/app}/controllers/qpcontroller.js (100%) rename {app => frontend/app}/helpers/balance-highlight-class.js (100%) rename {app => frontend/app}/helpers/format-duration.js (100%) rename {app => frontend/app}/helpers/humanize-duration.js (100%) rename {app => frontend/app}/helpers/parse-django-duration.js (100%) rename {app => frontend/app}/index.html (100%) rename {app => frontend/app}/index/activities/controller.js (100%) rename {app => frontend/app}/index/activities/edit/controller.js (100%) rename {app => frontend/app}/index/activities/edit/route.js (100%) rename {app => frontend/app}/index/activities/edit/template.hbs (100%) rename {app => frontend/app}/index/activities/route.js (100%) rename {app => frontend/app}/index/activities/template.hbs (100%) rename {app => frontend/app}/index/attendances/controller.js (100%) rename {app => frontend/app}/index/attendances/route.js (100%) rename {app => frontend/app}/index/attendances/template.hbs (100%) rename {app => frontend/app}/index/controller.js (100%) rename {app => frontend/app}/index/reports/controller.js (100%) rename {app => frontend/app}/index/reports/route.js (100%) rename {app => frontend/app}/index/reports/template.hbs (100%) rename {app => frontend/app}/index/route.js (100%) rename {app => frontend/app}/index/template.hbs (100%) rename {app => frontend/app}/initializers/responsive.js (100%) rename {app => frontend/app}/login/route.js (100%) rename {app => frontend/app}/login/template.hbs (100%) rename {app => frontend/app}/models/absence-balance.js (100%) rename {app => frontend/app}/models/absence-credit.js (100%) rename {app => frontend/app}/models/absence-type.js (100%) rename {app => frontend/app}/models/absence.js (100%) rename {app => frontend/app}/models/activity.js (100%) rename {app => frontend/app}/models/attendance.js (100%) rename {app => frontend/app}/models/billing-type.js (100%) rename {app => frontend/app}/models/cost-center.js (100%) rename {app => frontend/app}/models/customer-assignee.js (100%) rename {app => frontend/app}/models/customer-statistic.js (100%) rename {app => frontend/app}/models/customer.js (100%) rename {app => frontend/app}/models/employment.js (100%) rename {app => frontend/app}/models/location.js (100%) rename {app => frontend/app}/models/month-statistic.js (100%) rename {app => frontend/app}/models/overtime-credit.js (100%) rename {app => frontend/app}/models/project-assignee.js (100%) rename {app => frontend/app}/models/project-statistic.js (100%) rename {app => frontend/app}/models/project.js (100%) rename {app => frontend/app}/models/public-holiday.js (100%) rename {app => frontend/app}/models/report-intersection.js (100%) rename {app => frontend/app}/models/report.js (100%) rename {app => frontend/app}/models/task-assignee.js (100%) rename {app => frontend/app}/models/task-statistic.js (100%) rename {app => frontend/app}/models/task.js (100%) rename {app => frontend/app}/models/user-statistic.js (100%) rename {app => frontend/app}/models/user.js (100%) rename {app => frontend/app}/models/worktime-balance.js (100%) rename {app => frontend/app}/models/year-statistic.js (100%) rename {app => frontend/app}/no-access/route.js (100%) rename {app => frontend/app}/no-access/template.hbs (100%) rename {app => frontend/app}/notfound/route.js (100%) rename {app => frontend/app}/notfound/template.hbs (100%) rename {app => frontend/app}/projects/controller.js (100%) rename {app => frontend/app}/projects/route.js (100%) rename {app => frontend/app}/projects/template.hbs (100%) rename {app => frontend/app}/protected/controller.js (100%) rename {app => frontend/app}/protected/route.js (100%) rename {app => frontend/app}/protected/template.hbs (100%) rename {app => frontend/app}/router.js (100%) rename {app => frontend/app}/serializers/application.js (100%) rename {app => frontend/app}/serializers/attendance.js (100%) rename {app => frontend/app}/serializers/employment.js (100%) rename {app => frontend/app}/services/autostart-tour.js (100%) rename {app => frontend/app}/services/fetch.js (100%) rename {app => frontend/app}/services/metadata-fetcher.js (100%) rename {app => frontend/app}/services/rejected-reports.js (100%) rename {app => frontend/app}/services/tour.js (100%) rename {app => frontend/app}/services/tracking.js (100%) rename {app => frontend/app}/services/unverified-reports.js (100%) rename {app => frontend/app}/sso-login/route.js (100%) rename {app => frontend/app}/statistics/controller.js (100%) rename {app => frontend/app}/statistics/route.js (100%) rename {app => frontend/app}/statistics/template.hbs (100%) rename {app => frontend/app}/styles/activities.scss (100%) rename {app => frontend/app}/styles/adcssy.scss (100%) rename {app => frontend/app}/styles/analysis.scss (100%) rename {app => frontend/app}/styles/app.scss (100%) rename {app => frontend/app}/styles/attendances.scss (100%) rename {app => frontend/app}/styles/badge.scss (100%) rename {app => frontend/app}/styles/components/attendance-slider.scss (100%) rename {app => frontend/app}/styles/components/balance-donut.scss (100%) rename {app => frontend/app}/styles/components/date-buttons.scss (100%) rename {app => frontend/app}/styles/components/date-navigation.scss (100%) rename {app => frontend/app}/styles/components/filter-sidebar--group.scss (100%) rename {app => frontend/app}/styles/components/filter-sidebar--label.scss (100%) rename {app => frontend/app}/styles/components/loading-icon.scss (100%) rename {app => frontend/app}/styles/components/magic-link-btn.scss (100%) rename {app => frontend/app}/styles/components/nav-top.scss (100%) rename {app => frontend/app}/styles/components/progress-tooltip.scss (100%) rename {app => frontend/app}/styles/components/record-button.scss (100%) rename {app => frontend/app}/styles/components/scroll-container.scss (100%) rename {app => frontend/app}/styles/components/sort-header.scss (100%) rename {app => frontend/app}/styles/components/statistic-list-bar.scss (100%) rename {app => frontend/app}/styles/components/sy-calendar.scss (100%) rename {app => frontend/app}/styles/components/sy-checkbox.scss (100%) rename {app => frontend/app}/styles/components/sy-datepicker.scss (100%) rename {app => frontend/app}/styles/components/sy-durationpicker-day.scss (100%) rename {app => frontend/app}/styles/components/sy-modal--footer.scss (100%) rename {app => frontend/app}/styles/components/sy-modal--overlay.scss (100%) rename {app => frontend/app}/styles/components/sy-toggle.scss (100%) rename {app => frontend/app}/styles/components/timed-clock.scss (100%) rename {app => frontend/app}/styles/components/tracking-bar.scss (100%) rename {app => frontend/app}/styles/components/weekly-overview-benchmark.scss (100%) rename {app => frontend/app}/styles/components/weekly-overview-day.scss (100%) rename {app => frontend/app}/styles/components/weekly-overview.scss (100%) rename {app => frontend/app}/styles/components/welcome-modal.scss (100%) rename {app => frontend/app}/styles/ember-power-select-custom.scss (100%) rename {app => frontend/app}/styles/filter-sidebar.scss (100%) rename {app => frontend/app}/styles/form-list.scss (100%) rename {app => frontend/app}/styles/loader.scss (100%) rename {app => frontend/app}/styles/login.scss (100%) rename {app => frontend/app}/styles/projects.scss (100%) rename {app => frontend/app}/styles/reports.scss (100%) rename {app => frontend/app}/styles/statistics.scss (100%) rename {app => frontend/app}/styles/toolbar.scss (100%) rename {app => frontend/app}/styles/tour.scss (100%) rename {app => frontend/app}/styles/users-navigation.scss (100%) rename {app => frontend/app}/styles/users.scss (100%) rename {app => frontend/app}/styles/variables.scss (100%) rename {app => frontend/app}/tours/index.js (100%) rename {app => frontend/app}/tours/index/activities.js (100%) rename {app => frontend/app}/tours/index/attendances.js (100%) rename {app => frontend/app}/tours/index/reports.js (100%) rename {app => frontend/app}/transforms/django-date.js (100%) rename {app => frontend/app}/transforms/django-datetime.js (100%) rename {app => frontend/app}/transforms/django-duration.js (100%) rename {app => frontend/app}/transforms/django-time.js (100%) rename {app => frontend/app}/transforms/django-workdays.js (100%) rename {app => frontend/app}/transforms/moment.js (100%) rename {app => frontend/app}/users/edit/controller.js (100%) rename {app => frontend/app}/users/edit/credits/absence-credits/edit/controller.js (100%) rename {app => frontend/app}/users/edit/credits/absence-credits/edit/route.js (100%) rename {app => frontend/app}/users/edit/credits/absence-credits/edit/template.hbs (100%) rename {app => frontend/app}/users/edit/credits/absence-credits/new/route.js (100%) rename {app => frontend/app}/users/edit/credits/index/controller.js (100%) rename {app => frontend/app}/users/edit/credits/index/route.js (100%) rename {app => frontend/app}/users/edit/credits/index/template.hbs (100%) rename {app => frontend/app}/users/edit/credits/overtime-credits/edit/controller.js (100%) rename {app => frontend/app}/users/edit/credits/overtime-credits/edit/route.js (100%) rename {app => frontend/app}/users/edit/credits/overtime-credits/edit/template.hbs (100%) rename {app => frontend/app}/users/edit/credits/overtime-credits/new/route.js (100%) rename {app => frontend/app}/users/edit/credits/route.js (100%) rename {app => frontend/app}/users/edit/credits/template.hbs (100%) rename {app => frontend/app}/users/edit/index/controller.js (100%) rename {app => frontend/app}/users/edit/index/route.js (100%) rename {app => frontend/app}/users/edit/index/template.hbs (100%) rename {app => frontend/app}/users/edit/responsibilities/controller.js (100%) rename {app => frontend/app}/users/edit/responsibilities/route.js (100%) rename {app => frontend/app}/users/edit/responsibilities/template.hbs (100%) rename {app => frontend/app}/users/edit/route.js (100%) rename {app => frontend/app}/users/edit/template.hbs (100%) rename {app => frontend/app}/users/index/controller.js (100%) rename {app => frontend/app}/users/index/route.js (100%) rename {app => frontend/app}/users/index/template.hbs (100%) rename {app => frontend/app}/users/route.js (100%) rename {app => frontend/app}/users/template.hbs (100%) rename {app => frontend/app}/utils/format-duration.js (100%) rename {app => frontend/app}/utils/humanize-duration.js (100%) rename {app => frontend/app}/utils/parse-django-duration.js (100%) rename {app => frontend/app}/utils/query-params.js (100%) rename {app => frontend/app}/utils/serialize-moment.js (100%) rename {app => frontend/app}/utils/url.js (100%) rename {app => frontend/app}/validations/absence-credit.js (100%) rename {app => frontend/app}/validations/absence.js (100%) rename {app => frontend/app}/validations/activity.js (100%) rename {app => frontend/app}/validations/attendance.js (100%) rename {app => frontend/app}/validations/intersection.js (100%) rename {app => frontend/app}/validations/multiple-absence.js (100%) rename {app => frontend/app}/validations/overtime-credit.js (100%) rename {app => frontend/app}/validations/project.js (100%) rename {app => frontend/app}/validations/report.js (100%) rename {app => frontend/app}/validations/task.js (100%) rename {app => frontend/app}/validators/intersection-task.js (100%) rename {app => frontend/app}/validators/moment.js (100%) rename {app => frontend/app}/validators/null-or-not-blank.js (100%) rename {config => frontend/config}/coverage.js (100%) rename {config => frontend/config}/dependency-lint.js (100%) rename {config => frontend/config}/deprecation-workflow.js (100%) rename {config => frontend/config}/ember-cli-update.json (100%) rename {config => frontend/config}/environment.js (100%) rename {config => frontend/config}/icons.js (100%) rename {config => frontend/config}/optional-features.json (100%) rename {config => frontend/config}/targets.js (100%) rename {contrib => frontend/contrib}/nginx.conf (100%) rename docker-compose.yml => frontend/docker-compose.yml (100%) rename docker-entrypoint.sh => frontend/docker-entrypoint.sh (100%) rename ember-cli-build.js => frontend/ember-cli-build.js (100%) rename {mirage => frontend/mirage}/config.js (100%) rename {mirage => frontend/mirage}/factories/absence-balance.js (100%) rename {mirage => frontend/mirage}/factories/absence-credit.js (100%) rename {mirage => frontend/mirage}/factories/absence-type.js (100%) rename {mirage => frontend/mirage}/factories/absence.js (100%) rename {mirage => frontend/mirage}/factories/activity.js (100%) rename {mirage => frontend/mirage}/factories/attendance.js (100%) rename {mirage => frontend/mirage}/factories/billing-type.js (100%) rename {mirage => frontend/mirage}/factories/cost-center.js (100%) rename {mirage => frontend/mirage}/factories/customer-statistic.js (100%) rename {mirage => frontend/mirage}/factories/customer.js (100%) rename {mirage => frontend/mirage}/factories/employment.js (100%) rename {mirage => frontend/mirage}/factories/location.js (100%) rename {mirage => frontend/mirage}/factories/month-statistic.js (100%) rename {mirage => frontend/mirage}/factories/overtime-credit.js (100%) rename {mirage => frontend/mirage}/factories/project-assignee.js (100%) rename {mirage => frontend/mirage}/factories/project-statistic.js (100%) rename {mirage => frontend/mirage}/factories/project.js (100%) rename {mirage => frontend/mirage}/factories/public-holiday.js (100%) rename {mirage => frontend/mirage}/factories/report-intersection.js (100%) rename {mirage => frontend/mirage}/factories/report.js (100%) rename {mirage => frontend/mirage}/factories/task-statistic.js (100%) rename {mirage => frontend/mirage}/factories/task.js (100%) rename {mirage => frontend/mirage}/factories/user-statistic.js (100%) rename {mirage => frontend/mirage}/factories/user.js (100%) rename {mirage => frontend/mirage}/factories/worktime-balance.js (100%) rename {mirage => frontend/mirage}/factories/year-statistic.js (100%) rename {mirage => frontend/mirage}/fixtures/absence-types.js (100%) rename {mirage => frontend/mirage}/helpers/duration.js (100%) rename {mirage => frontend/mirage}/scenarios/default.js (100%) rename {mirage => frontend/mirage}/serializers/application.js (100%) rename package.json => frontend/package.json (100%) rename pnpm-lock.yaml => frontend/pnpm-lock.yaml (100%) rename {public => frontend/public}/assets/favicon-16x16.png (100%) rename {public => frontend/public}/assets/favicon-32x32.png (100%) rename {public => frontend/public}/assets/favicon.ico (100%) rename {public => frontend/public}/assets/logo.png (100%) rename {public => frontend/public}/assets/logo.svg (100%) rename {public => frontend/public}/assets/logo_text.png (100%) rename {public => frontend/public}/crossdomain.xml (100%) rename {public => frontend/public}/robots.txt (100%) rename renovate.json => frontend/renovate.json (100%) rename testem.js => frontend/testem.js (100%) rename {tests => frontend/tests}/.eslintrc.js (100%) rename {tests => frontend/tests}/acceptance/analysis-edit-test.js (100%) rename {tests => frontend/tests}/acceptance/analysis-test.js (100%) rename {tests => frontend/tests}/acceptance/auth-test.js (100%) rename {tests => frontend/tests}/acceptance/external-employee-test.js (100%) rename {tests => frontend/tests}/acceptance/index-activities-edit-test.js (100%) rename {tests => frontend/tests}/acceptance/index-activities-test.js (100%) rename {tests => frontend/tests}/acceptance/index-attendances-test.js (100%) rename {tests => frontend/tests}/acceptance/index-reports-test.js (100%) rename {tests => frontend/tests}/acceptance/index-test.js (100%) rename {tests => frontend/tests}/acceptance/magic-link-test.js (100%) rename {tests => frontend/tests}/acceptance/notfound-test.js (100%) rename {tests => frontend/tests}/acceptance/project-test.js (100%) rename {tests => frontend/tests}/acceptance/statistics-test.js (100%) rename {tests => frontend/tests}/acceptance/tour-test.js (100%) rename {tests => frontend/tests}/acceptance/users-edit-credits-absence-credit-test.js (100%) rename {tests => frontend/tests}/acceptance/users-edit-credits-overtime-credit-test.js (100%) rename {tests => frontend/tests}/acceptance/users-edit-credits-test.js (100%) rename {tests => frontend/tests}/acceptance/users-edit-responsibilities-test.js (100%) rename {tests => frontend/tests}/acceptance/users-edit-test.js (100%) rename {tests => frontend/tests}/acceptance/users-test.js (100%) rename {tests => frontend/tests}/helpers/index.js (100%) rename {tests => frontend/tests}/helpers/responsive.js (100%) rename {tests => frontend/tests}/helpers/session-mock.js (100%) rename {tests => frontend/tests}/helpers/task-select.js (100%) rename {tests => frontend/tests}/helpers/tracking-mock.js (100%) rename {tests => frontend/tests}/helpers/user-select.js (100%) rename {tests => frontend/tests}/index.html (100%) rename {tests => frontend/tests}/integration/components/async-list/component-test.js (100%) rename {tests => frontend/tests}/integration/components/attendance-slider/component-test.js (100%) rename {tests => frontend/tests}/integration/components/balance-donut/component-test.js (100%) rename {tests => frontend/tests}/integration/components/changed-warning/component-test.js (100%) rename {tests => frontend/tests}/integration/components/customer-visible-icon/component-test.js (100%) rename {tests => frontend/tests}/integration/components/date-buttons/component-test.js (100%) rename {tests => frontend/tests}/integration/components/date-navigation/component-test.js (100%) rename {tests => frontend/tests}/integration/components/duration-since/component-test.js (100%) rename {tests => frontend/tests}/integration/components/filter-sidebar/component-test.js (100%) rename {tests => frontend/tests}/integration/components/filter-sidebar/filter/component-test.js (100%) rename {tests => frontend/tests}/integration/components/filter-sidebar/group/component-test.js (100%) rename {tests => frontend/tests}/integration/components/filter-sidebar/label/component-test.js (100%) rename {tests => frontend/tests}/integration/components/in-viewport/component-test.js (100%) rename {tests => frontend/tests}/integration/components/loading-icon/component-test.js (100%) rename {tests => frontend/tests}/integration/components/no-mobile-message/component-test.js (100%) rename {tests => frontend/tests}/integration/components/no-permission/component-test.js (100%) rename {tests => frontend/tests}/integration/components/not-identical-warning/component-test.js (100%) rename {tests => frontend/tests}/integration/components/optimized-power-select/component-test.js (100%) rename {tests => frontend/tests}/integration/components/progress-tooltip/component-test.js (100%) rename {tests => frontend/tests}/integration/components/record-button/component-test.js (100%) rename {tests => frontend/tests}/integration/components/report-review-warning/component-test.js (100%) rename {tests => frontend/tests}/integration/components/report-row/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sort-header/component-test.js (100%) rename {tests => frontend/tests}/integration/components/statistic-list/bar/component-test.js (100%) rename {tests => frontend/tests}/integration/components/statistic-list/column/component-test.js (100%) rename {tests => frontend/tests}/integration/components/statistic-list/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-calendar/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-checkbox/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-checkmark/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-datepicker-btn/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-datepicker/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-durationpicker-day/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-durationpicker/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-modal-target/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-modal/body/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-modal/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-modal/footer/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-modal/header/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-modal/overlay/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-timepicker/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-toggle/component-test.js (100%) rename {tests => frontend/tests}/integration/components/sy-topnav/component-test.js (100%) rename {tests => frontend/tests}/integration/components/task-selection/component-test.js (100%) rename {tests => frontend/tests}/integration/components/timed-clock/component-test.js (100%) rename {tests => frontend/tests}/integration/components/tracking-bar/component-test.js (100%) rename {tests => frontend/tests}/integration/components/user-selection/component-test.js (100%) rename {tests => frontend/tests}/integration/components/weekly-overview-benchmark/component-test.js (100%) rename {tests => frontend/tests}/integration/components/weekly-overview-day/component-test.js (100%) rename {tests => frontend/tests}/integration/components/weekly-overview/component-test.js (100%) rename {tests => frontend/tests}/integration/components/welcome-modal/component-test.js (100%) rename {tests => frontend/tests}/integration/components/worktime-balance-chart/component-test.js (100%) rename {tests => frontend/tests}/test-helper.js (100%) rename {tests => frontend/tests}/unit/abilities/report-test.js (100%) rename {tests => frontend/tests}/unit/analysis/edit/controller-test.js (100%) rename {tests => frontend/tests}/unit/analysis/edit/route-test.js (100%) rename {tests => frontend/tests}/unit/analysis/index/controller-test.js (100%) rename {tests => frontend/tests}/unit/analysis/index/route-test.js (100%) rename {tests => frontend/tests}/unit/analysis/route-test.js (100%) rename {tests => frontend/tests}/unit/controllers/qpcontroller/controller-test.js (100%) rename {tests => frontend/tests}/unit/helpers/balance-highlight-class-test.js (100%) rename {tests => frontend/tests}/unit/helpers/format-duration-test.js (100%) rename {tests => frontend/tests}/unit/helpers/humanize-duration-test.js (100%) rename {tests => frontend/tests}/unit/helpers/parse-django-duration-test.js (100%) rename {tests => frontend/tests}/unit/index/activities/controller-test.js (100%) rename {tests => frontend/tests}/unit/index/activities/edit/controller-test.js (100%) rename {tests => frontend/tests}/unit/index/activities/edit/route-test.js (100%) rename {tests => frontend/tests}/unit/index/activities/route-test.js (100%) rename {tests => frontend/tests}/unit/index/attendances/controller-test.js (100%) rename {tests => frontend/tests}/unit/index/attendances/route-test.js (100%) rename {tests => frontend/tests}/unit/index/controller-test.js (100%) rename {tests => frontend/tests}/unit/index/reports/controller-test.js (100%) rename {tests => frontend/tests}/unit/index/reports/route-test.js (100%) rename {tests => frontend/tests}/unit/index/route-test.js (100%) rename {tests => frontend/tests}/unit/login/route-test.js (100%) rename {tests => frontend/tests}/unit/models/absence-balance-test.js (100%) rename {tests => frontend/tests}/unit/models/activity-test.js (100%) rename {tests => frontend/tests}/unit/models/attendance-test.js (100%) rename {tests => frontend/tests}/unit/models/billing-type-test.js (100%) rename {tests => frontend/tests}/unit/models/cost-center-test.js (100%) rename {tests => frontend/tests}/unit/models/customer-statistic-test.js (100%) rename {tests => frontend/tests}/unit/models/customer-test.js (100%) rename {tests => frontend/tests}/unit/models/employment-test.js (100%) rename {tests => frontend/tests}/unit/models/location-test.js (100%) rename {tests => frontend/tests}/unit/models/month-statistic-test.js (100%) rename {tests => frontend/tests}/unit/models/overtime-credit-test.js (100%) rename {tests => frontend/tests}/unit/models/project-statistic-test.js (100%) rename {tests => frontend/tests}/unit/models/project-test.js (100%) rename {tests => frontend/tests}/unit/models/public-holiday-test.js (100%) rename {tests => frontend/tests}/unit/models/report-intersection-test.js (100%) rename {tests => frontend/tests}/unit/models/report-test.js (100%) rename {tests => frontend/tests}/unit/models/task-statistic-test.js (100%) rename {tests => frontend/tests}/unit/models/task-test.js (100%) rename {tests => frontend/tests}/unit/models/user-statistic-test.js (100%) rename {tests => frontend/tests}/unit/models/user-test.js (100%) rename {tests => frontend/tests}/unit/models/worktime-balance-test.js (100%) rename {tests => frontend/tests}/unit/models/year-statistic-test.js (100%) rename {tests => frontend/tests}/unit/no-access/route-test.js (100%) rename {tests => frontend/tests}/unit/notfound/route-test.js (100%) rename {tests => frontend/tests}/unit/projects/controller-test.js (100%) rename {tests => frontend/tests}/unit/projects/route-test.js (100%) rename {tests => frontend/tests}/unit/protected/controller-test.js (100%) rename {tests => frontend/tests}/unit/protected/route-test.js (100%) rename {tests => frontend/tests}/unit/serializers/attendance-test.js (100%) rename {tests => frontend/tests}/unit/serializers/employment-test.js (100%) rename {tests => frontend/tests}/unit/services/autostart-tour-test.js (100%) rename {tests => frontend/tests}/unit/services/fetch-test.js (100%) rename {tests => frontend/tests}/unit/services/metadata-fetcher-test.js (100%) rename {tests => frontend/tests}/unit/services/rejected-reports-test.js (100%) rename {tests => frontend/tests}/unit/services/tracking-test.js (100%) rename {tests => frontend/tests}/unit/services/unverified-reports-test.js (100%) rename {tests => frontend/tests}/unit/sso-login/route-test.js (100%) rename {tests => frontend/tests}/unit/statistics/controller-test.js (100%) rename {tests => frontend/tests}/unit/statistics/route-test.js (100%) rename {tests => frontend/tests}/unit/transforms/django-date-test.js (100%) rename {tests => frontend/tests}/unit/transforms/django-datetime-test.js (100%) rename {tests => frontend/tests}/unit/transforms/django-duration-test.js (100%) rename {tests => frontend/tests}/unit/transforms/django-time-test.js (100%) rename {tests => frontend/tests}/unit/transforms/django-workdays-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/controller-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/absence-credits/edit/controller-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/absence-credits/edit/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/absence-credits/new/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/index/controller-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/index/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/overtime-credits/edit/controller-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/overtime-credits/edit/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/overtime-credits/new/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/credits/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/index/controller-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/index/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/responsibilities/controller-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/responsibilities/route-test.js (100%) rename {tests => frontend/tests}/unit/users/edit/route-test.js (100%) rename {tests => frontend/tests}/unit/users/index/controller-test.js (100%) rename {tests => frontend/tests}/unit/users/index/route-test.js (100%) rename {tests => frontend/tests}/unit/users/route-test.js (100%) rename {tests => frontend/tests}/unit/utils/format-duration-test.js (100%) rename {tests => frontend/tests}/unit/utils/humanize-duration-test.js (100%) rename {tests => frontend/tests}/unit/utils/parse-django-duration-test.js (100%) rename {tests => frontend/tests}/unit/utils/query-params-test.js (100%) rename {tests => frontend/tests}/unit/utils/url-test.js (100%) rename {tests => frontend/tests}/unit/validators/moment-test.js (100%) rename {tests => frontend/tests}/unit/validators/null-or-not-blank-test.js (100%) rename {vendor => frontend/vendor}/.gitkeep (100%) diff --git a/.bowerrc b/frontend/.bowerrc similarity index 100% rename from .bowerrc rename to frontend/.bowerrc diff --git a/.dockerignore b/frontend/.dockerignore similarity index 100% rename from .dockerignore rename to frontend/.dockerignore diff --git a/.editorconfig b/frontend/.editorconfig similarity index 100% rename from .editorconfig rename to frontend/.editorconfig diff --git a/.ember-cli b/frontend/.ember-cli similarity index 100% rename from .ember-cli rename to frontend/.ember-cli diff --git a/.eslintignore b/frontend/.eslintignore similarity index 100% rename from .eslintignore rename to frontend/.eslintignore diff --git a/.eslintrc.js b/frontend/.eslintrc.js similarity index 100% rename from .eslintrc.js rename to frontend/.eslintrc.js diff --git a/.github/dependabot.yml b/frontend/.github/dependabot.yml similarity index 100% rename from .github/dependabot.yml rename to frontend/.github/dependabot.yml diff --git a/.github/workflows/release-image.yml b/frontend/.github/workflows/release-image.yml similarity index 100% rename from .github/workflows/release-image.yml rename to frontend/.github/workflows/release-image.yml diff --git a/.github/workflows/release-npm.yml b/frontend/.github/workflows/release-npm.yml similarity index 100% rename from .github/workflows/release-npm.yml rename to frontend/.github/workflows/release-npm.yml diff --git a/.github/workflows/test.yml b/frontend/.github/workflows/test.yml similarity index 100% rename from .github/workflows/test.yml rename to frontend/.github/workflows/test.yml diff --git a/.gitignore b/frontend/.gitignore similarity index 100% rename from .gitignore rename to frontend/.gitignore diff --git a/.husky/commit-msg b/frontend/.husky/commit-msg similarity index 100% rename from .husky/commit-msg rename to frontend/.husky/commit-msg diff --git a/.husky/pre-commit b/frontend/.husky/pre-commit similarity index 100% rename from .husky/pre-commit rename to frontend/.husky/pre-commit diff --git a/.npmrc b/frontend/.npmrc similarity index 100% rename from .npmrc rename to frontend/.npmrc diff --git a/.prettierignore b/frontend/.prettierignore similarity index 100% rename from .prettierignore rename to frontend/.prettierignore diff --git a/.template-lintrc-ci.js b/frontend/.template-lintrc-ci.js similarity index 100% rename from .template-lintrc-ci.js rename to frontend/.template-lintrc-ci.js diff --git a/.template-lintrc.js b/frontend/.template-lintrc.js similarity index 100% rename from .template-lintrc.js rename to frontend/.template-lintrc.js diff --git a/.watchmanconfig b/frontend/.watchmanconfig similarity index 100% rename from .watchmanconfig rename to frontend/.watchmanconfig diff --git a/CHANGELOG.md b/frontend/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to frontend/CHANGELOG.md diff --git a/Dockerfile b/frontend/Dockerfile similarity index 100% rename from Dockerfile rename to frontend/Dockerfile diff --git a/LICENSE b/frontend/LICENSE similarity index 100% rename from LICENSE rename to frontend/LICENSE diff --git a/README.md b/frontend/README.md similarity index 100% rename from README.md rename to frontend/README.md diff --git a/app/abilities/absence-credit.js b/frontend/app/abilities/absence-credit.js similarity index 100% rename from app/abilities/absence-credit.js rename to frontend/app/abilities/absence-credit.js diff --git a/app/abilities/overtime-credit.js b/frontend/app/abilities/overtime-credit.js similarity index 100% rename from app/abilities/overtime-credit.js rename to frontend/app/abilities/overtime-credit.js diff --git a/app/abilities/page.js b/frontend/app/abilities/page.js similarity index 100% rename from app/abilities/page.js rename to frontend/app/abilities/page.js diff --git a/app/abilities/report.js b/frontend/app/abilities/report.js similarity index 100% rename from app/abilities/report.js rename to frontend/app/abilities/report.js diff --git a/app/abilities/user.js b/frontend/app/abilities/user.js similarity index 100% rename from app/abilities/user.js rename to frontend/app/abilities/user.js diff --git a/app/adapters/activity-block.js b/frontend/app/adapters/activity-block.js similarity index 100% rename from app/adapters/activity-block.js rename to frontend/app/adapters/activity-block.js diff --git a/app/adapters/application.js b/frontend/app/adapters/application.js similarity index 100% rename from app/adapters/application.js rename to frontend/app/adapters/application.js diff --git a/app/analysis/edit/controller.js b/frontend/app/analysis/edit/controller.js similarity index 100% rename from app/analysis/edit/controller.js rename to frontend/app/analysis/edit/controller.js diff --git a/app/analysis/edit/route.js b/frontend/app/analysis/edit/route.js similarity index 100% rename from app/analysis/edit/route.js rename to frontend/app/analysis/edit/route.js diff --git a/app/analysis/edit/template.hbs b/frontend/app/analysis/edit/template.hbs similarity index 100% rename from app/analysis/edit/template.hbs rename to frontend/app/analysis/edit/template.hbs diff --git a/app/analysis/index/controller.js b/frontend/app/analysis/index/controller.js similarity index 100% rename from app/analysis/index/controller.js rename to frontend/app/analysis/index/controller.js diff --git a/app/analysis/index/route.js b/frontend/app/analysis/index/route.js similarity index 100% rename from app/analysis/index/route.js rename to frontend/app/analysis/index/route.js diff --git a/app/analysis/index/template.hbs b/frontend/app/analysis/index/template.hbs similarity index 100% rename from app/analysis/index/template.hbs rename to frontend/app/analysis/index/template.hbs diff --git a/app/analysis/route.js b/frontend/app/analysis/route.js similarity index 100% rename from app/analysis/route.js rename to frontend/app/analysis/route.js diff --git a/app/app.js b/frontend/app/app.js similarity index 100% rename from app/app.js rename to frontend/app/app.js diff --git a/app/application/route.js b/frontend/app/application/route.js similarity index 100% rename from app/application/route.js rename to frontend/app/application/route.js diff --git a/app/application/template.hbs b/frontend/app/application/template.hbs similarity index 100% rename from app/application/template.hbs rename to frontend/app/application/template.hbs diff --git a/app/breakpoints.js b/frontend/app/breakpoints.js similarity index 100% rename from app/breakpoints.js rename to frontend/app/breakpoints.js diff --git a/app/components/async-list/template.hbs b/frontend/app/components/async-list/template.hbs similarity index 100% rename from app/components/async-list/template.hbs rename to frontend/app/components/async-list/template.hbs diff --git a/app/components/attendance-slider/component.js b/frontend/app/components/attendance-slider/component.js similarity index 100% rename from app/components/attendance-slider/component.js rename to frontend/app/components/attendance-slider/component.js diff --git a/app/components/attendance-slider/template.hbs b/frontend/app/components/attendance-slider/template.hbs similarity index 100% rename from app/components/attendance-slider/template.hbs rename to frontend/app/components/attendance-slider/template.hbs diff --git a/app/components/balance-donut/component.js b/frontend/app/components/balance-donut/component.js similarity index 100% rename from app/components/balance-donut/component.js rename to frontend/app/components/balance-donut/component.js diff --git a/app/components/balance-donut/template.hbs b/frontend/app/components/balance-donut/template.hbs similarity index 100% rename from app/components/balance-donut/template.hbs rename to frontend/app/components/balance-donut/template.hbs diff --git a/app/components/changed-warning/template.hbs b/frontend/app/components/changed-warning/template.hbs similarity index 100% rename from app/components/changed-warning/template.hbs rename to frontend/app/components/changed-warning/template.hbs diff --git a/app/components/customer-visible-icon/template.hbs b/frontend/app/components/customer-visible-icon/template.hbs similarity index 100% rename from app/components/customer-visible-icon/template.hbs rename to frontend/app/components/customer-visible-icon/template.hbs diff --git a/app/components/date-buttons/component.js b/frontend/app/components/date-buttons/component.js similarity index 100% rename from app/components/date-buttons/component.js rename to frontend/app/components/date-buttons/component.js diff --git a/app/components/date-buttons/template.hbs b/frontend/app/components/date-buttons/template.hbs similarity index 100% rename from app/components/date-buttons/template.hbs rename to frontend/app/components/date-buttons/template.hbs diff --git a/app/components/date-navigation/component.js b/frontend/app/components/date-navigation/component.js similarity index 100% rename from app/components/date-navigation/component.js rename to frontend/app/components/date-navigation/component.js diff --git a/app/components/date-navigation/template.hbs b/frontend/app/components/date-navigation/template.hbs similarity index 100% rename from app/components/date-navigation/template.hbs rename to frontend/app/components/date-navigation/template.hbs diff --git a/app/components/duration-since/component.js b/frontend/app/components/duration-since/component.js similarity index 100% rename from app/components/duration-since/component.js rename to frontend/app/components/duration-since/component.js diff --git a/app/components/duration-since/template.hbs b/frontend/app/components/duration-since/template.hbs similarity index 100% rename from app/components/duration-since/template.hbs rename to frontend/app/components/duration-since/template.hbs diff --git a/app/components/filter-sidebar/component.js b/frontend/app/components/filter-sidebar/component.js similarity index 100% rename from app/components/filter-sidebar/component.js rename to frontend/app/components/filter-sidebar/component.js diff --git a/app/components/filter-sidebar/filter/template.hbs b/frontend/app/components/filter-sidebar/filter/template.hbs similarity index 100% rename from app/components/filter-sidebar/filter/template.hbs rename to frontend/app/components/filter-sidebar/filter/template.hbs diff --git a/app/components/filter-sidebar/group/component.js b/frontend/app/components/filter-sidebar/group/component.js similarity index 100% rename from app/components/filter-sidebar/group/component.js rename to frontend/app/components/filter-sidebar/group/component.js diff --git a/app/components/filter-sidebar/group/styles.scss b/frontend/app/components/filter-sidebar/group/styles.scss similarity index 100% rename from app/components/filter-sidebar/group/styles.scss rename to frontend/app/components/filter-sidebar/group/styles.scss diff --git a/app/components/filter-sidebar/group/template.hbs b/frontend/app/components/filter-sidebar/group/template.hbs similarity index 100% rename from app/components/filter-sidebar/group/template.hbs rename to frontend/app/components/filter-sidebar/group/template.hbs diff --git a/app/components/filter-sidebar/label/template.hbs b/frontend/app/components/filter-sidebar/label/template.hbs similarity index 100% rename from app/components/filter-sidebar/label/template.hbs rename to frontend/app/components/filter-sidebar/label/template.hbs diff --git a/app/components/filter-sidebar/template.hbs b/frontend/app/components/filter-sidebar/template.hbs similarity index 100% rename from app/components/filter-sidebar/template.hbs rename to frontend/app/components/filter-sidebar/template.hbs diff --git a/app/components/in-viewport/component.js b/frontend/app/components/in-viewport/component.js similarity index 100% rename from app/components/in-viewport/component.js rename to frontend/app/components/in-viewport/component.js diff --git a/app/components/in-viewport/template.hbs b/frontend/app/components/in-viewport/template.hbs similarity index 100% rename from app/components/in-viewport/template.hbs rename to frontend/app/components/in-viewport/template.hbs diff --git a/app/components/loading-icon/template.hbs b/frontend/app/components/loading-icon/template.hbs similarity index 100% rename from app/components/loading-icon/template.hbs rename to frontend/app/components/loading-icon/template.hbs diff --git a/app/components/magic-link-btn/component.js b/frontend/app/components/magic-link-btn/component.js similarity index 100% rename from app/components/magic-link-btn/component.js rename to frontend/app/components/magic-link-btn/component.js diff --git a/app/components/magic-link-btn/template.hbs b/frontend/app/components/magic-link-btn/template.hbs similarity index 100% rename from app/components/magic-link-btn/template.hbs rename to frontend/app/components/magic-link-btn/template.hbs diff --git a/app/components/magic-link-modal/component.js b/frontend/app/components/magic-link-modal/component.js similarity index 100% rename from app/components/magic-link-modal/component.js rename to frontend/app/components/magic-link-modal/component.js diff --git a/app/components/magic-link-modal/template.hbs b/frontend/app/components/magic-link-modal/template.hbs similarity index 100% rename from app/components/magic-link-modal/template.hbs rename to frontend/app/components/magic-link-modal/template.hbs diff --git a/app/components/no-mobile-message/template.hbs b/frontend/app/components/no-mobile-message/template.hbs similarity index 100% rename from app/components/no-mobile-message/template.hbs rename to frontend/app/components/no-mobile-message/template.hbs diff --git a/app/components/no-permission/template.hbs b/frontend/app/components/no-permission/template.hbs similarity index 100% rename from app/components/no-permission/template.hbs rename to frontend/app/components/no-permission/template.hbs diff --git a/app/components/not-identical-warning/template.hbs b/frontend/app/components/not-identical-warning/template.hbs similarity index 100% rename from app/components/not-identical-warning/template.hbs rename to frontend/app/components/not-identical-warning/template.hbs diff --git a/app/components/optimized-power-select/component.js b/frontend/app/components/optimized-power-select/component.js similarity index 100% rename from app/components/optimized-power-select/component.js rename to frontend/app/components/optimized-power-select/component.js diff --git a/app/components/optimized-power-select/custom-options/customer-option.hbs b/frontend/app/components/optimized-power-select/custom-options/customer-option.hbs similarity index 100% rename from app/components/optimized-power-select/custom-options/customer-option.hbs rename to frontend/app/components/optimized-power-select/custom-options/customer-option.hbs diff --git a/app/components/optimized-power-select/custom-options/project-option.hbs b/frontend/app/components/optimized-power-select/custom-options/project-option.hbs similarity index 100% rename from app/components/optimized-power-select/custom-options/project-option.hbs rename to frontend/app/components/optimized-power-select/custom-options/project-option.hbs diff --git a/app/components/optimized-power-select/custom-options/task-option.hbs b/frontend/app/components/optimized-power-select/custom-options/task-option.hbs similarity index 100% rename from app/components/optimized-power-select/custom-options/task-option.hbs rename to frontend/app/components/optimized-power-select/custom-options/task-option.hbs diff --git a/app/components/optimized-power-select/custom-options/user-option.hbs b/frontend/app/components/optimized-power-select/custom-options/user-option.hbs similarity index 100% rename from app/components/optimized-power-select/custom-options/user-option.hbs rename to frontend/app/components/optimized-power-select/custom-options/user-option.hbs diff --git a/app/components/optimized-power-select/custom-select/task-selection.hbs b/frontend/app/components/optimized-power-select/custom-select/task-selection.hbs similarity index 100% rename from app/components/optimized-power-select/custom-select/task-selection.hbs rename to frontend/app/components/optimized-power-select/custom-select/task-selection.hbs diff --git a/app/components/optimized-power-select/custom-select/user-selection.hbs b/frontend/app/components/optimized-power-select/custom-select/user-selection.hbs similarity index 100% rename from app/components/optimized-power-select/custom-select/user-selection.hbs rename to frontend/app/components/optimized-power-select/custom-select/user-selection.hbs diff --git a/app/components/optimized-power-select/options/component.js b/frontend/app/components/optimized-power-select/options/component.js similarity index 100% rename from app/components/optimized-power-select/options/component.js rename to frontend/app/components/optimized-power-select/options/component.js diff --git a/app/components/optimized-power-select/options/template.hbs b/frontend/app/components/optimized-power-select/options/template.hbs similarity index 100% rename from app/components/optimized-power-select/options/template.hbs rename to frontend/app/components/optimized-power-select/options/template.hbs diff --git a/app/components/optimized-power-select/template.hbs b/frontend/app/components/optimized-power-select/template.hbs similarity index 100% rename from app/components/optimized-power-select/template.hbs rename to frontend/app/components/optimized-power-select/template.hbs diff --git a/app/components/optimized-power-select/trigger/component.js b/frontend/app/components/optimized-power-select/trigger/component.js similarity index 100% rename from app/components/optimized-power-select/trigger/component.js rename to frontend/app/components/optimized-power-select/trigger/component.js diff --git a/app/components/optimized-power-select/trigger/template.hbs b/frontend/app/components/optimized-power-select/trigger/template.hbs similarity index 100% rename from app/components/optimized-power-select/trigger/template.hbs rename to frontend/app/components/optimized-power-select/trigger/template.hbs diff --git a/app/components/page-permission/template.hbs b/frontend/app/components/page-permission/template.hbs similarity index 100% rename from app/components/page-permission/template.hbs rename to frontend/app/components/page-permission/template.hbs diff --git a/app/components/progress-tooltip/component.js b/frontend/app/components/progress-tooltip/component.js similarity index 100% rename from app/components/progress-tooltip/component.js rename to frontend/app/components/progress-tooltip/component.js diff --git a/app/components/progress-tooltip/template.hbs b/frontend/app/components/progress-tooltip/template.hbs similarity index 100% rename from app/components/progress-tooltip/template.hbs rename to frontend/app/components/progress-tooltip/template.hbs diff --git a/app/components/record-button/component.js b/frontend/app/components/record-button/component.js similarity index 100% rename from app/components/record-button/component.js rename to frontend/app/components/record-button/component.js diff --git a/app/components/record-button/template.hbs b/frontend/app/components/record-button/template.hbs similarity index 100% rename from app/components/record-button/template.hbs rename to frontend/app/components/record-button/template.hbs diff --git a/app/components/report-review-warning/component.js b/frontend/app/components/report-review-warning/component.js similarity index 100% rename from app/components/report-review-warning/component.js rename to frontend/app/components/report-review-warning/component.js diff --git a/app/components/report-review-warning/template.hbs b/frontend/app/components/report-review-warning/template.hbs similarity index 100% rename from app/components/report-review-warning/template.hbs rename to frontend/app/components/report-review-warning/template.hbs diff --git a/app/components/report-row/component.js b/frontend/app/components/report-row/component.js similarity index 100% rename from app/components/report-row/component.js rename to frontend/app/components/report-row/component.js diff --git a/app/components/report-row/template.hbs b/frontend/app/components/report-row/template.hbs similarity index 100% rename from app/components/report-row/template.hbs rename to frontend/app/components/report-row/template.hbs diff --git a/app/components/scroll-container.hbs b/frontend/app/components/scroll-container.hbs similarity index 100% rename from app/components/scroll-container.hbs rename to frontend/app/components/scroll-container.hbs diff --git a/app/components/sort-header/component.js b/frontend/app/components/sort-header/component.js similarity index 100% rename from app/components/sort-header/component.js rename to frontend/app/components/sort-header/component.js diff --git a/app/components/sort-header/template.hbs b/frontend/app/components/sort-header/template.hbs similarity index 100% rename from app/components/sort-header/template.hbs rename to frontend/app/components/sort-header/template.hbs diff --git a/app/components/statistic-list/bar/component.js b/frontend/app/components/statistic-list/bar/component.js similarity index 100% rename from app/components/statistic-list/bar/component.js rename to frontend/app/components/statistic-list/bar/component.js diff --git a/app/components/statistic-list/bar/template.hbs b/frontend/app/components/statistic-list/bar/template.hbs similarity index 100% rename from app/components/statistic-list/bar/template.hbs rename to frontend/app/components/statistic-list/bar/template.hbs diff --git a/app/components/statistic-list/column/template.hbs b/frontend/app/components/statistic-list/column/template.hbs similarity index 100% rename from app/components/statistic-list/column/template.hbs rename to frontend/app/components/statistic-list/column/template.hbs diff --git a/app/components/statistic-list/component.js b/frontend/app/components/statistic-list/component.js similarity index 100% rename from app/components/statistic-list/component.js rename to frontend/app/components/statistic-list/component.js diff --git a/app/components/statistic-list/template.hbs b/frontend/app/components/statistic-list/template.hbs similarity index 100% rename from app/components/statistic-list/template.hbs rename to frontend/app/components/statistic-list/template.hbs diff --git a/app/components/sy-calendar/component.js b/frontend/app/components/sy-calendar/component.js similarity index 100% rename from app/components/sy-calendar/component.js rename to frontend/app/components/sy-calendar/component.js diff --git a/app/components/sy-calendar/styles.scss b/frontend/app/components/sy-calendar/styles.scss similarity index 100% rename from app/components/sy-calendar/styles.scss rename to frontend/app/components/sy-calendar/styles.scss diff --git a/app/components/sy-calendar/template.hbs b/frontend/app/components/sy-calendar/template.hbs similarity index 100% rename from app/components/sy-calendar/template.hbs rename to frontend/app/components/sy-calendar/template.hbs diff --git a/app/components/sy-checkbox/component.js b/frontend/app/components/sy-checkbox/component.js similarity index 100% rename from app/components/sy-checkbox/component.js rename to frontend/app/components/sy-checkbox/component.js diff --git a/app/components/sy-checkbox/template.hbs b/frontend/app/components/sy-checkbox/template.hbs similarity index 100% rename from app/components/sy-checkbox/template.hbs rename to frontend/app/components/sy-checkbox/template.hbs diff --git a/app/components/sy-checkmark/component.js b/frontend/app/components/sy-checkmark/component.js similarity index 100% rename from app/components/sy-checkmark/component.js rename to frontend/app/components/sy-checkmark/component.js diff --git a/app/components/sy-checkmark/template.hbs b/frontend/app/components/sy-checkmark/template.hbs similarity index 100% rename from app/components/sy-checkmark/template.hbs rename to frontend/app/components/sy-checkmark/template.hbs diff --git a/app/components/sy-datepicker-btn/component.js b/frontend/app/components/sy-datepicker-btn/component.js similarity index 100% rename from app/components/sy-datepicker-btn/component.js rename to frontend/app/components/sy-datepicker-btn/component.js diff --git a/app/components/sy-datepicker-btn/template.hbs b/frontend/app/components/sy-datepicker-btn/template.hbs similarity index 100% rename from app/components/sy-datepicker-btn/template.hbs rename to frontend/app/components/sy-datepicker-btn/template.hbs diff --git a/app/components/sy-datepicker/component.js b/frontend/app/components/sy-datepicker/component.js similarity index 100% rename from app/components/sy-datepicker/component.js rename to frontend/app/components/sy-datepicker/component.js diff --git a/app/components/sy-datepicker/template.hbs b/frontend/app/components/sy-datepicker/template.hbs similarity index 100% rename from app/components/sy-datepicker/template.hbs rename to frontend/app/components/sy-datepicker/template.hbs diff --git a/app/components/sy-durationpicker-day/component.js b/frontend/app/components/sy-durationpicker-day/component.js similarity index 100% rename from app/components/sy-durationpicker-day/component.js rename to frontend/app/components/sy-durationpicker-day/component.js diff --git a/app/components/sy-durationpicker-day/template.hbs b/frontend/app/components/sy-durationpicker-day/template.hbs similarity index 100% rename from app/components/sy-durationpicker-day/template.hbs rename to frontend/app/components/sy-durationpicker-day/template.hbs diff --git a/app/components/sy-durationpicker/component.js b/frontend/app/components/sy-durationpicker/component.js similarity index 100% rename from app/components/sy-durationpicker/component.js rename to frontend/app/components/sy-durationpicker/component.js diff --git a/app/components/sy-durationpicker/template.hbs b/frontend/app/components/sy-durationpicker/template.hbs similarity index 100% rename from app/components/sy-durationpicker/template.hbs rename to frontend/app/components/sy-durationpicker/template.hbs diff --git a/app/components/sy-modal-target/template.hbs b/frontend/app/components/sy-modal-target/template.hbs similarity index 100% rename from app/components/sy-modal-target/template.hbs rename to frontend/app/components/sy-modal-target/template.hbs diff --git a/app/components/sy-modal/body/styles.scss b/frontend/app/components/sy-modal/body/styles.scss similarity index 100% rename from app/components/sy-modal/body/styles.scss rename to frontend/app/components/sy-modal/body/styles.scss diff --git a/app/components/sy-modal/body/template.hbs b/frontend/app/components/sy-modal/body/template.hbs similarity index 100% rename from app/components/sy-modal/body/template.hbs rename to frontend/app/components/sy-modal/body/template.hbs diff --git a/app/components/sy-modal/footer/template.hbs b/frontend/app/components/sy-modal/footer/template.hbs similarity index 100% rename from app/components/sy-modal/footer/template.hbs rename to frontend/app/components/sy-modal/footer/template.hbs diff --git a/app/components/sy-modal/header/template.hbs b/frontend/app/components/sy-modal/header/template.hbs similarity index 100% rename from app/components/sy-modal/header/template.hbs rename to frontend/app/components/sy-modal/header/template.hbs diff --git a/app/components/sy-modal/overlay/component.js b/frontend/app/components/sy-modal/overlay/component.js similarity index 100% rename from app/components/sy-modal/overlay/component.js rename to frontend/app/components/sy-modal/overlay/component.js diff --git a/app/components/sy-modal/overlay/template.hbs b/frontend/app/components/sy-modal/overlay/template.hbs similarity index 100% rename from app/components/sy-modal/overlay/template.hbs rename to frontend/app/components/sy-modal/overlay/template.hbs diff --git a/app/components/sy-modal/template.hbs b/frontend/app/components/sy-modal/template.hbs similarity index 100% rename from app/components/sy-modal/template.hbs rename to frontend/app/components/sy-modal/template.hbs diff --git a/app/components/sy-timepicker/component.js b/frontend/app/components/sy-timepicker/component.js similarity index 100% rename from app/components/sy-timepicker/component.js rename to frontend/app/components/sy-timepicker/component.js diff --git a/app/components/sy-timepicker/template.hbs b/frontend/app/components/sy-timepicker/template.hbs similarity index 100% rename from app/components/sy-timepicker/template.hbs rename to frontend/app/components/sy-timepicker/template.hbs diff --git a/app/components/sy-toggle/component.js b/frontend/app/components/sy-toggle/component.js similarity index 100% rename from app/components/sy-toggle/component.js rename to frontend/app/components/sy-toggle/component.js diff --git a/app/components/sy-toggle/template.hbs b/frontend/app/components/sy-toggle/template.hbs similarity index 100% rename from app/components/sy-toggle/template.hbs rename to frontend/app/components/sy-toggle/template.hbs diff --git a/app/components/sy-topnav/component.js b/frontend/app/components/sy-topnav/component.js similarity index 100% rename from app/components/sy-topnav/component.js rename to frontend/app/components/sy-topnav/component.js diff --git a/app/components/sy-topnav/template.hbs b/frontend/app/components/sy-topnav/template.hbs similarity index 100% rename from app/components/sy-topnav/template.hbs rename to frontend/app/components/sy-topnav/template.hbs diff --git a/app/components/task-selection/component.js b/frontend/app/components/task-selection/component.js similarity index 100% rename from app/components/task-selection/component.js rename to frontend/app/components/task-selection/component.js diff --git a/app/components/task-selection/template.hbs b/frontend/app/components/task-selection/template.hbs similarity index 100% rename from app/components/task-selection/template.hbs rename to frontend/app/components/task-selection/template.hbs diff --git a/app/components/timed-clock/component.js b/frontend/app/components/timed-clock/component.js similarity index 100% rename from app/components/timed-clock/component.js rename to frontend/app/components/timed-clock/component.js diff --git a/app/components/timed-clock/template.hbs b/frontend/app/components/timed-clock/template.hbs similarity index 100% rename from app/components/timed-clock/template.hbs rename to frontend/app/components/timed-clock/template.hbs diff --git a/app/components/tracking-bar/component.js b/frontend/app/components/tracking-bar/component.js similarity index 100% rename from app/components/tracking-bar/component.js rename to frontend/app/components/tracking-bar/component.js diff --git a/app/components/tracking-bar/template.hbs b/frontend/app/components/tracking-bar/template.hbs similarity index 100% rename from app/components/tracking-bar/template.hbs rename to frontend/app/components/tracking-bar/template.hbs diff --git a/app/components/user-selection/component.js b/frontend/app/components/user-selection/component.js similarity index 100% rename from app/components/user-selection/component.js rename to frontend/app/components/user-selection/component.js diff --git a/app/components/user-selection/template.hbs b/frontend/app/components/user-selection/template.hbs similarity index 100% rename from app/components/user-selection/template.hbs rename to frontend/app/components/user-selection/template.hbs diff --git a/app/components/vertical-collection/component.js b/frontend/app/components/vertical-collection/component.js similarity index 100% rename from app/components/vertical-collection/component.js rename to frontend/app/components/vertical-collection/component.js diff --git a/app/components/weekly-overview-benchmark/component.js b/frontend/app/components/weekly-overview-benchmark/component.js similarity index 100% rename from app/components/weekly-overview-benchmark/component.js rename to frontend/app/components/weekly-overview-benchmark/component.js diff --git a/app/components/weekly-overview-benchmark/template.hbs b/frontend/app/components/weekly-overview-benchmark/template.hbs similarity index 100% rename from app/components/weekly-overview-benchmark/template.hbs rename to frontend/app/components/weekly-overview-benchmark/template.hbs diff --git a/app/components/weekly-overview-day/component.js b/frontend/app/components/weekly-overview-day/component.js similarity index 100% rename from app/components/weekly-overview-day/component.js rename to frontend/app/components/weekly-overview-day/component.js diff --git a/app/components/weekly-overview-day/template.hbs b/frontend/app/components/weekly-overview-day/template.hbs similarity index 100% rename from app/components/weekly-overview-day/template.hbs rename to frontend/app/components/weekly-overview-day/template.hbs diff --git a/app/components/weekly-overview/component.js b/frontend/app/components/weekly-overview/component.js similarity index 100% rename from app/components/weekly-overview/component.js rename to frontend/app/components/weekly-overview/component.js diff --git a/app/components/weekly-overview/template.hbs b/frontend/app/components/weekly-overview/template.hbs similarity index 100% rename from app/components/weekly-overview/template.hbs rename to frontend/app/components/weekly-overview/template.hbs diff --git a/app/components/welcome-modal/template.hbs b/frontend/app/components/welcome-modal/template.hbs similarity index 100% rename from app/components/welcome-modal/template.hbs rename to frontend/app/components/welcome-modal/template.hbs diff --git a/app/components/worktime-balance-chart/component.js b/frontend/app/components/worktime-balance-chart/component.js similarity index 100% rename from app/components/worktime-balance-chart/component.js rename to frontend/app/components/worktime-balance-chart/component.js diff --git a/app/components/worktime-balance-chart/template.hbs b/frontend/app/components/worktime-balance-chart/template.hbs similarity index 100% rename from app/components/worktime-balance-chart/template.hbs rename to frontend/app/components/worktime-balance-chart/template.hbs diff --git a/app/controllers/qpcontroller.js b/frontend/app/controllers/qpcontroller.js similarity index 100% rename from app/controllers/qpcontroller.js rename to frontend/app/controllers/qpcontroller.js diff --git a/app/helpers/balance-highlight-class.js b/frontend/app/helpers/balance-highlight-class.js similarity index 100% rename from app/helpers/balance-highlight-class.js rename to frontend/app/helpers/balance-highlight-class.js diff --git a/app/helpers/format-duration.js b/frontend/app/helpers/format-duration.js similarity index 100% rename from app/helpers/format-duration.js rename to frontend/app/helpers/format-duration.js diff --git a/app/helpers/humanize-duration.js b/frontend/app/helpers/humanize-duration.js similarity index 100% rename from app/helpers/humanize-duration.js rename to frontend/app/helpers/humanize-duration.js diff --git a/app/helpers/parse-django-duration.js b/frontend/app/helpers/parse-django-duration.js similarity index 100% rename from app/helpers/parse-django-duration.js rename to frontend/app/helpers/parse-django-duration.js diff --git a/app/index.html b/frontend/app/index.html similarity index 100% rename from app/index.html rename to frontend/app/index.html diff --git a/app/index/activities/controller.js b/frontend/app/index/activities/controller.js similarity index 100% rename from app/index/activities/controller.js rename to frontend/app/index/activities/controller.js diff --git a/app/index/activities/edit/controller.js b/frontend/app/index/activities/edit/controller.js similarity index 100% rename from app/index/activities/edit/controller.js rename to frontend/app/index/activities/edit/controller.js diff --git a/app/index/activities/edit/route.js b/frontend/app/index/activities/edit/route.js similarity index 100% rename from app/index/activities/edit/route.js rename to frontend/app/index/activities/edit/route.js diff --git a/app/index/activities/edit/template.hbs b/frontend/app/index/activities/edit/template.hbs similarity index 100% rename from app/index/activities/edit/template.hbs rename to frontend/app/index/activities/edit/template.hbs diff --git a/app/index/activities/route.js b/frontend/app/index/activities/route.js similarity index 100% rename from app/index/activities/route.js rename to frontend/app/index/activities/route.js diff --git a/app/index/activities/template.hbs b/frontend/app/index/activities/template.hbs similarity index 100% rename from app/index/activities/template.hbs rename to frontend/app/index/activities/template.hbs diff --git a/app/index/attendances/controller.js b/frontend/app/index/attendances/controller.js similarity index 100% rename from app/index/attendances/controller.js rename to frontend/app/index/attendances/controller.js diff --git a/app/index/attendances/route.js b/frontend/app/index/attendances/route.js similarity index 100% rename from app/index/attendances/route.js rename to frontend/app/index/attendances/route.js diff --git a/app/index/attendances/template.hbs b/frontend/app/index/attendances/template.hbs similarity index 100% rename from app/index/attendances/template.hbs rename to frontend/app/index/attendances/template.hbs diff --git a/app/index/controller.js b/frontend/app/index/controller.js similarity index 100% rename from app/index/controller.js rename to frontend/app/index/controller.js diff --git a/app/index/reports/controller.js b/frontend/app/index/reports/controller.js similarity index 100% rename from app/index/reports/controller.js rename to frontend/app/index/reports/controller.js diff --git a/app/index/reports/route.js b/frontend/app/index/reports/route.js similarity index 100% rename from app/index/reports/route.js rename to frontend/app/index/reports/route.js diff --git a/app/index/reports/template.hbs b/frontend/app/index/reports/template.hbs similarity index 100% rename from app/index/reports/template.hbs rename to frontend/app/index/reports/template.hbs diff --git a/app/index/route.js b/frontend/app/index/route.js similarity index 100% rename from app/index/route.js rename to frontend/app/index/route.js diff --git a/app/index/template.hbs b/frontend/app/index/template.hbs similarity index 100% rename from app/index/template.hbs rename to frontend/app/index/template.hbs diff --git a/app/initializers/responsive.js b/frontend/app/initializers/responsive.js similarity index 100% rename from app/initializers/responsive.js rename to frontend/app/initializers/responsive.js diff --git a/app/login/route.js b/frontend/app/login/route.js similarity index 100% rename from app/login/route.js rename to frontend/app/login/route.js diff --git a/app/login/template.hbs b/frontend/app/login/template.hbs similarity index 100% rename from app/login/template.hbs rename to frontend/app/login/template.hbs diff --git a/app/models/absence-balance.js b/frontend/app/models/absence-balance.js similarity index 100% rename from app/models/absence-balance.js rename to frontend/app/models/absence-balance.js diff --git a/app/models/absence-credit.js b/frontend/app/models/absence-credit.js similarity index 100% rename from app/models/absence-credit.js rename to frontend/app/models/absence-credit.js diff --git a/app/models/absence-type.js b/frontend/app/models/absence-type.js similarity index 100% rename from app/models/absence-type.js rename to frontend/app/models/absence-type.js diff --git a/app/models/absence.js b/frontend/app/models/absence.js similarity index 100% rename from app/models/absence.js rename to frontend/app/models/absence.js diff --git a/app/models/activity.js b/frontend/app/models/activity.js similarity index 100% rename from app/models/activity.js rename to frontend/app/models/activity.js diff --git a/app/models/attendance.js b/frontend/app/models/attendance.js similarity index 100% rename from app/models/attendance.js rename to frontend/app/models/attendance.js diff --git a/app/models/billing-type.js b/frontend/app/models/billing-type.js similarity index 100% rename from app/models/billing-type.js rename to frontend/app/models/billing-type.js diff --git a/app/models/cost-center.js b/frontend/app/models/cost-center.js similarity index 100% rename from app/models/cost-center.js rename to frontend/app/models/cost-center.js diff --git a/app/models/customer-assignee.js b/frontend/app/models/customer-assignee.js similarity index 100% rename from app/models/customer-assignee.js rename to frontend/app/models/customer-assignee.js diff --git a/app/models/customer-statistic.js b/frontend/app/models/customer-statistic.js similarity index 100% rename from app/models/customer-statistic.js rename to frontend/app/models/customer-statistic.js diff --git a/app/models/customer.js b/frontend/app/models/customer.js similarity index 100% rename from app/models/customer.js rename to frontend/app/models/customer.js diff --git a/app/models/employment.js b/frontend/app/models/employment.js similarity index 100% rename from app/models/employment.js rename to frontend/app/models/employment.js diff --git a/app/models/location.js b/frontend/app/models/location.js similarity index 100% rename from app/models/location.js rename to frontend/app/models/location.js diff --git a/app/models/month-statistic.js b/frontend/app/models/month-statistic.js similarity index 100% rename from app/models/month-statistic.js rename to frontend/app/models/month-statistic.js diff --git a/app/models/overtime-credit.js b/frontend/app/models/overtime-credit.js similarity index 100% rename from app/models/overtime-credit.js rename to frontend/app/models/overtime-credit.js diff --git a/app/models/project-assignee.js b/frontend/app/models/project-assignee.js similarity index 100% rename from app/models/project-assignee.js rename to frontend/app/models/project-assignee.js diff --git a/app/models/project-statistic.js b/frontend/app/models/project-statistic.js similarity index 100% rename from app/models/project-statistic.js rename to frontend/app/models/project-statistic.js diff --git a/app/models/project.js b/frontend/app/models/project.js similarity index 100% rename from app/models/project.js rename to frontend/app/models/project.js diff --git a/app/models/public-holiday.js b/frontend/app/models/public-holiday.js similarity index 100% rename from app/models/public-holiday.js rename to frontend/app/models/public-holiday.js diff --git a/app/models/report-intersection.js b/frontend/app/models/report-intersection.js similarity index 100% rename from app/models/report-intersection.js rename to frontend/app/models/report-intersection.js diff --git a/app/models/report.js b/frontend/app/models/report.js similarity index 100% rename from app/models/report.js rename to frontend/app/models/report.js diff --git a/app/models/task-assignee.js b/frontend/app/models/task-assignee.js similarity index 100% rename from app/models/task-assignee.js rename to frontend/app/models/task-assignee.js diff --git a/app/models/task-statistic.js b/frontend/app/models/task-statistic.js similarity index 100% rename from app/models/task-statistic.js rename to frontend/app/models/task-statistic.js diff --git a/app/models/task.js b/frontend/app/models/task.js similarity index 100% rename from app/models/task.js rename to frontend/app/models/task.js diff --git a/app/models/user-statistic.js b/frontend/app/models/user-statistic.js similarity index 100% rename from app/models/user-statistic.js rename to frontend/app/models/user-statistic.js diff --git a/app/models/user.js b/frontend/app/models/user.js similarity index 100% rename from app/models/user.js rename to frontend/app/models/user.js diff --git a/app/models/worktime-balance.js b/frontend/app/models/worktime-balance.js similarity index 100% rename from app/models/worktime-balance.js rename to frontend/app/models/worktime-balance.js diff --git a/app/models/year-statistic.js b/frontend/app/models/year-statistic.js similarity index 100% rename from app/models/year-statistic.js rename to frontend/app/models/year-statistic.js diff --git a/app/no-access/route.js b/frontend/app/no-access/route.js similarity index 100% rename from app/no-access/route.js rename to frontend/app/no-access/route.js diff --git a/app/no-access/template.hbs b/frontend/app/no-access/template.hbs similarity index 100% rename from app/no-access/template.hbs rename to frontend/app/no-access/template.hbs diff --git a/app/notfound/route.js b/frontend/app/notfound/route.js similarity index 100% rename from app/notfound/route.js rename to frontend/app/notfound/route.js diff --git a/app/notfound/template.hbs b/frontend/app/notfound/template.hbs similarity index 100% rename from app/notfound/template.hbs rename to frontend/app/notfound/template.hbs diff --git a/app/projects/controller.js b/frontend/app/projects/controller.js similarity index 100% rename from app/projects/controller.js rename to frontend/app/projects/controller.js diff --git a/app/projects/route.js b/frontend/app/projects/route.js similarity index 100% rename from app/projects/route.js rename to frontend/app/projects/route.js diff --git a/app/projects/template.hbs b/frontend/app/projects/template.hbs similarity index 100% rename from app/projects/template.hbs rename to frontend/app/projects/template.hbs diff --git a/app/protected/controller.js b/frontend/app/protected/controller.js similarity index 100% rename from app/protected/controller.js rename to frontend/app/protected/controller.js diff --git a/app/protected/route.js b/frontend/app/protected/route.js similarity index 100% rename from app/protected/route.js rename to frontend/app/protected/route.js diff --git a/app/protected/template.hbs b/frontend/app/protected/template.hbs similarity index 100% rename from app/protected/template.hbs rename to frontend/app/protected/template.hbs diff --git a/app/router.js b/frontend/app/router.js similarity index 100% rename from app/router.js rename to frontend/app/router.js diff --git a/app/serializers/application.js b/frontend/app/serializers/application.js similarity index 100% rename from app/serializers/application.js rename to frontend/app/serializers/application.js diff --git a/app/serializers/attendance.js b/frontend/app/serializers/attendance.js similarity index 100% rename from app/serializers/attendance.js rename to frontend/app/serializers/attendance.js diff --git a/app/serializers/employment.js b/frontend/app/serializers/employment.js similarity index 100% rename from app/serializers/employment.js rename to frontend/app/serializers/employment.js diff --git a/app/services/autostart-tour.js b/frontend/app/services/autostart-tour.js similarity index 100% rename from app/services/autostart-tour.js rename to frontend/app/services/autostart-tour.js diff --git a/app/services/fetch.js b/frontend/app/services/fetch.js similarity index 100% rename from app/services/fetch.js rename to frontend/app/services/fetch.js diff --git a/app/services/metadata-fetcher.js b/frontend/app/services/metadata-fetcher.js similarity index 100% rename from app/services/metadata-fetcher.js rename to frontend/app/services/metadata-fetcher.js diff --git a/app/services/rejected-reports.js b/frontend/app/services/rejected-reports.js similarity index 100% rename from app/services/rejected-reports.js rename to frontend/app/services/rejected-reports.js diff --git a/app/services/tour.js b/frontend/app/services/tour.js similarity index 100% rename from app/services/tour.js rename to frontend/app/services/tour.js diff --git a/app/services/tracking.js b/frontend/app/services/tracking.js similarity index 100% rename from app/services/tracking.js rename to frontend/app/services/tracking.js diff --git a/app/services/unverified-reports.js b/frontend/app/services/unverified-reports.js similarity index 100% rename from app/services/unverified-reports.js rename to frontend/app/services/unverified-reports.js diff --git a/app/sso-login/route.js b/frontend/app/sso-login/route.js similarity index 100% rename from app/sso-login/route.js rename to frontend/app/sso-login/route.js diff --git a/app/statistics/controller.js b/frontend/app/statistics/controller.js similarity index 100% rename from app/statistics/controller.js rename to frontend/app/statistics/controller.js diff --git a/app/statistics/route.js b/frontend/app/statistics/route.js similarity index 100% rename from app/statistics/route.js rename to frontend/app/statistics/route.js diff --git a/app/statistics/template.hbs b/frontend/app/statistics/template.hbs similarity index 100% rename from app/statistics/template.hbs rename to frontend/app/statistics/template.hbs diff --git a/app/styles/activities.scss b/frontend/app/styles/activities.scss similarity index 100% rename from app/styles/activities.scss rename to frontend/app/styles/activities.scss diff --git a/app/styles/adcssy.scss b/frontend/app/styles/adcssy.scss similarity index 100% rename from app/styles/adcssy.scss rename to frontend/app/styles/adcssy.scss diff --git a/app/styles/analysis.scss b/frontend/app/styles/analysis.scss similarity index 100% rename from app/styles/analysis.scss rename to frontend/app/styles/analysis.scss diff --git a/app/styles/app.scss b/frontend/app/styles/app.scss similarity index 100% rename from app/styles/app.scss rename to frontend/app/styles/app.scss diff --git a/app/styles/attendances.scss b/frontend/app/styles/attendances.scss similarity index 100% rename from app/styles/attendances.scss rename to frontend/app/styles/attendances.scss diff --git a/app/styles/badge.scss b/frontend/app/styles/badge.scss similarity index 100% rename from app/styles/badge.scss rename to frontend/app/styles/badge.scss diff --git a/app/styles/components/attendance-slider.scss b/frontend/app/styles/components/attendance-slider.scss similarity index 100% rename from app/styles/components/attendance-slider.scss rename to frontend/app/styles/components/attendance-slider.scss diff --git a/app/styles/components/balance-donut.scss b/frontend/app/styles/components/balance-donut.scss similarity index 100% rename from app/styles/components/balance-donut.scss rename to frontend/app/styles/components/balance-donut.scss diff --git a/app/styles/components/date-buttons.scss b/frontend/app/styles/components/date-buttons.scss similarity index 100% rename from app/styles/components/date-buttons.scss rename to frontend/app/styles/components/date-buttons.scss diff --git a/app/styles/components/date-navigation.scss b/frontend/app/styles/components/date-navigation.scss similarity index 100% rename from app/styles/components/date-navigation.scss rename to frontend/app/styles/components/date-navigation.scss diff --git a/app/styles/components/filter-sidebar--group.scss b/frontend/app/styles/components/filter-sidebar--group.scss similarity index 100% rename from app/styles/components/filter-sidebar--group.scss rename to frontend/app/styles/components/filter-sidebar--group.scss diff --git a/app/styles/components/filter-sidebar--label.scss b/frontend/app/styles/components/filter-sidebar--label.scss similarity index 100% rename from app/styles/components/filter-sidebar--label.scss rename to frontend/app/styles/components/filter-sidebar--label.scss diff --git a/app/styles/components/loading-icon.scss b/frontend/app/styles/components/loading-icon.scss similarity index 100% rename from app/styles/components/loading-icon.scss rename to frontend/app/styles/components/loading-icon.scss diff --git a/app/styles/components/magic-link-btn.scss b/frontend/app/styles/components/magic-link-btn.scss similarity index 100% rename from app/styles/components/magic-link-btn.scss rename to frontend/app/styles/components/magic-link-btn.scss diff --git a/app/styles/components/nav-top.scss b/frontend/app/styles/components/nav-top.scss similarity index 100% rename from app/styles/components/nav-top.scss rename to frontend/app/styles/components/nav-top.scss diff --git a/app/styles/components/progress-tooltip.scss b/frontend/app/styles/components/progress-tooltip.scss similarity index 100% rename from app/styles/components/progress-tooltip.scss rename to frontend/app/styles/components/progress-tooltip.scss diff --git a/app/styles/components/record-button.scss b/frontend/app/styles/components/record-button.scss similarity index 100% rename from app/styles/components/record-button.scss rename to frontend/app/styles/components/record-button.scss diff --git a/app/styles/components/scroll-container.scss b/frontend/app/styles/components/scroll-container.scss similarity index 100% rename from app/styles/components/scroll-container.scss rename to frontend/app/styles/components/scroll-container.scss diff --git a/app/styles/components/sort-header.scss b/frontend/app/styles/components/sort-header.scss similarity index 100% rename from app/styles/components/sort-header.scss rename to frontend/app/styles/components/sort-header.scss diff --git a/app/styles/components/statistic-list-bar.scss b/frontend/app/styles/components/statistic-list-bar.scss similarity index 100% rename from app/styles/components/statistic-list-bar.scss rename to frontend/app/styles/components/statistic-list-bar.scss diff --git a/app/styles/components/sy-calendar.scss b/frontend/app/styles/components/sy-calendar.scss similarity index 100% rename from app/styles/components/sy-calendar.scss rename to frontend/app/styles/components/sy-calendar.scss diff --git a/app/styles/components/sy-checkbox.scss b/frontend/app/styles/components/sy-checkbox.scss similarity index 100% rename from app/styles/components/sy-checkbox.scss rename to frontend/app/styles/components/sy-checkbox.scss diff --git a/app/styles/components/sy-datepicker.scss b/frontend/app/styles/components/sy-datepicker.scss similarity index 100% rename from app/styles/components/sy-datepicker.scss rename to frontend/app/styles/components/sy-datepicker.scss diff --git a/app/styles/components/sy-durationpicker-day.scss b/frontend/app/styles/components/sy-durationpicker-day.scss similarity index 100% rename from app/styles/components/sy-durationpicker-day.scss rename to frontend/app/styles/components/sy-durationpicker-day.scss diff --git a/app/styles/components/sy-modal--footer.scss b/frontend/app/styles/components/sy-modal--footer.scss similarity index 100% rename from app/styles/components/sy-modal--footer.scss rename to frontend/app/styles/components/sy-modal--footer.scss diff --git a/app/styles/components/sy-modal--overlay.scss b/frontend/app/styles/components/sy-modal--overlay.scss similarity index 100% rename from app/styles/components/sy-modal--overlay.scss rename to frontend/app/styles/components/sy-modal--overlay.scss diff --git a/app/styles/components/sy-toggle.scss b/frontend/app/styles/components/sy-toggle.scss similarity index 100% rename from app/styles/components/sy-toggle.scss rename to frontend/app/styles/components/sy-toggle.scss diff --git a/app/styles/components/timed-clock.scss b/frontend/app/styles/components/timed-clock.scss similarity index 100% rename from app/styles/components/timed-clock.scss rename to frontend/app/styles/components/timed-clock.scss diff --git a/app/styles/components/tracking-bar.scss b/frontend/app/styles/components/tracking-bar.scss similarity index 100% rename from app/styles/components/tracking-bar.scss rename to frontend/app/styles/components/tracking-bar.scss diff --git a/app/styles/components/weekly-overview-benchmark.scss b/frontend/app/styles/components/weekly-overview-benchmark.scss similarity index 100% rename from app/styles/components/weekly-overview-benchmark.scss rename to frontend/app/styles/components/weekly-overview-benchmark.scss diff --git a/app/styles/components/weekly-overview-day.scss b/frontend/app/styles/components/weekly-overview-day.scss similarity index 100% rename from app/styles/components/weekly-overview-day.scss rename to frontend/app/styles/components/weekly-overview-day.scss diff --git a/app/styles/components/weekly-overview.scss b/frontend/app/styles/components/weekly-overview.scss similarity index 100% rename from app/styles/components/weekly-overview.scss rename to frontend/app/styles/components/weekly-overview.scss diff --git a/app/styles/components/welcome-modal.scss b/frontend/app/styles/components/welcome-modal.scss similarity index 100% rename from app/styles/components/welcome-modal.scss rename to frontend/app/styles/components/welcome-modal.scss diff --git a/app/styles/ember-power-select-custom.scss b/frontend/app/styles/ember-power-select-custom.scss similarity index 100% rename from app/styles/ember-power-select-custom.scss rename to frontend/app/styles/ember-power-select-custom.scss diff --git a/app/styles/filter-sidebar.scss b/frontend/app/styles/filter-sidebar.scss similarity index 100% rename from app/styles/filter-sidebar.scss rename to frontend/app/styles/filter-sidebar.scss diff --git a/app/styles/form-list.scss b/frontend/app/styles/form-list.scss similarity index 100% rename from app/styles/form-list.scss rename to frontend/app/styles/form-list.scss diff --git a/app/styles/loader.scss b/frontend/app/styles/loader.scss similarity index 100% rename from app/styles/loader.scss rename to frontend/app/styles/loader.scss diff --git a/app/styles/login.scss b/frontend/app/styles/login.scss similarity index 100% rename from app/styles/login.scss rename to frontend/app/styles/login.scss diff --git a/app/styles/projects.scss b/frontend/app/styles/projects.scss similarity index 100% rename from app/styles/projects.scss rename to frontend/app/styles/projects.scss diff --git a/app/styles/reports.scss b/frontend/app/styles/reports.scss similarity index 100% rename from app/styles/reports.scss rename to frontend/app/styles/reports.scss diff --git a/app/styles/statistics.scss b/frontend/app/styles/statistics.scss similarity index 100% rename from app/styles/statistics.scss rename to frontend/app/styles/statistics.scss diff --git a/app/styles/toolbar.scss b/frontend/app/styles/toolbar.scss similarity index 100% rename from app/styles/toolbar.scss rename to frontend/app/styles/toolbar.scss diff --git a/app/styles/tour.scss b/frontend/app/styles/tour.scss similarity index 100% rename from app/styles/tour.scss rename to frontend/app/styles/tour.scss diff --git a/app/styles/users-navigation.scss b/frontend/app/styles/users-navigation.scss similarity index 100% rename from app/styles/users-navigation.scss rename to frontend/app/styles/users-navigation.scss diff --git a/app/styles/users.scss b/frontend/app/styles/users.scss similarity index 100% rename from app/styles/users.scss rename to frontend/app/styles/users.scss diff --git a/app/styles/variables.scss b/frontend/app/styles/variables.scss similarity index 100% rename from app/styles/variables.scss rename to frontend/app/styles/variables.scss diff --git a/app/tours/index.js b/frontend/app/tours/index.js similarity index 100% rename from app/tours/index.js rename to frontend/app/tours/index.js diff --git a/app/tours/index/activities.js b/frontend/app/tours/index/activities.js similarity index 100% rename from app/tours/index/activities.js rename to frontend/app/tours/index/activities.js diff --git a/app/tours/index/attendances.js b/frontend/app/tours/index/attendances.js similarity index 100% rename from app/tours/index/attendances.js rename to frontend/app/tours/index/attendances.js diff --git a/app/tours/index/reports.js b/frontend/app/tours/index/reports.js similarity index 100% rename from app/tours/index/reports.js rename to frontend/app/tours/index/reports.js diff --git a/app/transforms/django-date.js b/frontend/app/transforms/django-date.js similarity index 100% rename from app/transforms/django-date.js rename to frontend/app/transforms/django-date.js diff --git a/app/transforms/django-datetime.js b/frontend/app/transforms/django-datetime.js similarity index 100% rename from app/transforms/django-datetime.js rename to frontend/app/transforms/django-datetime.js diff --git a/app/transforms/django-duration.js b/frontend/app/transforms/django-duration.js similarity index 100% rename from app/transforms/django-duration.js rename to frontend/app/transforms/django-duration.js diff --git a/app/transforms/django-time.js b/frontend/app/transforms/django-time.js similarity index 100% rename from app/transforms/django-time.js rename to frontend/app/transforms/django-time.js diff --git a/app/transforms/django-workdays.js b/frontend/app/transforms/django-workdays.js similarity index 100% rename from app/transforms/django-workdays.js rename to frontend/app/transforms/django-workdays.js diff --git a/app/transforms/moment.js b/frontend/app/transforms/moment.js similarity index 100% rename from app/transforms/moment.js rename to frontend/app/transforms/moment.js diff --git a/app/users/edit/controller.js b/frontend/app/users/edit/controller.js similarity index 100% rename from app/users/edit/controller.js rename to frontend/app/users/edit/controller.js diff --git a/app/users/edit/credits/absence-credits/edit/controller.js b/frontend/app/users/edit/credits/absence-credits/edit/controller.js similarity index 100% rename from app/users/edit/credits/absence-credits/edit/controller.js rename to frontend/app/users/edit/credits/absence-credits/edit/controller.js diff --git a/app/users/edit/credits/absence-credits/edit/route.js b/frontend/app/users/edit/credits/absence-credits/edit/route.js similarity index 100% rename from app/users/edit/credits/absence-credits/edit/route.js rename to frontend/app/users/edit/credits/absence-credits/edit/route.js diff --git a/app/users/edit/credits/absence-credits/edit/template.hbs b/frontend/app/users/edit/credits/absence-credits/edit/template.hbs similarity index 100% rename from app/users/edit/credits/absence-credits/edit/template.hbs rename to frontend/app/users/edit/credits/absence-credits/edit/template.hbs diff --git a/app/users/edit/credits/absence-credits/new/route.js b/frontend/app/users/edit/credits/absence-credits/new/route.js similarity index 100% rename from app/users/edit/credits/absence-credits/new/route.js rename to frontend/app/users/edit/credits/absence-credits/new/route.js diff --git a/app/users/edit/credits/index/controller.js b/frontend/app/users/edit/credits/index/controller.js similarity index 100% rename from app/users/edit/credits/index/controller.js rename to frontend/app/users/edit/credits/index/controller.js diff --git a/app/users/edit/credits/index/route.js b/frontend/app/users/edit/credits/index/route.js similarity index 100% rename from app/users/edit/credits/index/route.js rename to frontend/app/users/edit/credits/index/route.js diff --git a/app/users/edit/credits/index/template.hbs b/frontend/app/users/edit/credits/index/template.hbs similarity index 100% rename from app/users/edit/credits/index/template.hbs rename to frontend/app/users/edit/credits/index/template.hbs diff --git a/app/users/edit/credits/overtime-credits/edit/controller.js b/frontend/app/users/edit/credits/overtime-credits/edit/controller.js similarity index 100% rename from app/users/edit/credits/overtime-credits/edit/controller.js rename to frontend/app/users/edit/credits/overtime-credits/edit/controller.js diff --git a/app/users/edit/credits/overtime-credits/edit/route.js b/frontend/app/users/edit/credits/overtime-credits/edit/route.js similarity index 100% rename from app/users/edit/credits/overtime-credits/edit/route.js rename to frontend/app/users/edit/credits/overtime-credits/edit/route.js diff --git a/app/users/edit/credits/overtime-credits/edit/template.hbs b/frontend/app/users/edit/credits/overtime-credits/edit/template.hbs similarity index 100% rename from app/users/edit/credits/overtime-credits/edit/template.hbs rename to frontend/app/users/edit/credits/overtime-credits/edit/template.hbs diff --git a/app/users/edit/credits/overtime-credits/new/route.js b/frontend/app/users/edit/credits/overtime-credits/new/route.js similarity index 100% rename from app/users/edit/credits/overtime-credits/new/route.js rename to frontend/app/users/edit/credits/overtime-credits/new/route.js diff --git a/app/users/edit/credits/route.js b/frontend/app/users/edit/credits/route.js similarity index 100% rename from app/users/edit/credits/route.js rename to frontend/app/users/edit/credits/route.js diff --git a/app/users/edit/credits/template.hbs b/frontend/app/users/edit/credits/template.hbs similarity index 100% rename from app/users/edit/credits/template.hbs rename to frontend/app/users/edit/credits/template.hbs diff --git a/app/users/edit/index/controller.js b/frontend/app/users/edit/index/controller.js similarity index 100% rename from app/users/edit/index/controller.js rename to frontend/app/users/edit/index/controller.js diff --git a/app/users/edit/index/route.js b/frontend/app/users/edit/index/route.js similarity index 100% rename from app/users/edit/index/route.js rename to frontend/app/users/edit/index/route.js diff --git a/app/users/edit/index/template.hbs b/frontend/app/users/edit/index/template.hbs similarity index 100% rename from app/users/edit/index/template.hbs rename to frontend/app/users/edit/index/template.hbs diff --git a/app/users/edit/responsibilities/controller.js b/frontend/app/users/edit/responsibilities/controller.js similarity index 100% rename from app/users/edit/responsibilities/controller.js rename to frontend/app/users/edit/responsibilities/controller.js diff --git a/app/users/edit/responsibilities/route.js b/frontend/app/users/edit/responsibilities/route.js similarity index 100% rename from app/users/edit/responsibilities/route.js rename to frontend/app/users/edit/responsibilities/route.js diff --git a/app/users/edit/responsibilities/template.hbs b/frontend/app/users/edit/responsibilities/template.hbs similarity index 100% rename from app/users/edit/responsibilities/template.hbs rename to frontend/app/users/edit/responsibilities/template.hbs diff --git a/app/users/edit/route.js b/frontend/app/users/edit/route.js similarity index 100% rename from app/users/edit/route.js rename to frontend/app/users/edit/route.js diff --git a/app/users/edit/template.hbs b/frontend/app/users/edit/template.hbs similarity index 100% rename from app/users/edit/template.hbs rename to frontend/app/users/edit/template.hbs diff --git a/app/users/index/controller.js b/frontend/app/users/index/controller.js similarity index 100% rename from app/users/index/controller.js rename to frontend/app/users/index/controller.js diff --git a/app/users/index/route.js b/frontend/app/users/index/route.js similarity index 100% rename from app/users/index/route.js rename to frontend/app/users/index/route.js diff --git a/app/users/index/template.hbs b/frontend/app/users/index/template.hbs similarity index 100% rename from app/users/index/template.hbs rename to frontend/app/users/index/template.hbs diff --git a/app/users/route.js b/frontend/app/users/route.js similarity index 100% rename from app/users/route.js rename to frontend/app/users/route.js diff --git a/app/users/template.hbs b/frontend/app/users/template.hbs similarity index 100% rename from app/users/template.hbs rename to frontend/app/users/template.hbs diff --git a/app/utils/format-duration.js b/frontend/app/utils/format-duration.js similarity index 100% rename from app/utils/format-duration.js rename to frontend/app/utils/format-duration.js diff --git a/app/utils/humanize-duration.js b/frontend/app/utils/humanize-duration.js similarity index 100% rename from app/utils/humanize-duration.js rename to frontend/app/utils/humanize-duration.js diff --git a/app/utils/parse-django-duration.js b/frontend/app/utils/parse-django-duration.js similarity index 100% rename from app/utils/parse-django-duration.js rename to frontend/app/utils/parse-django-duration.js diff --git a/app/utils/query-params.js b/frontend/app/utils/query-params.js similarity index 100% rename from app/utils/query-params.js rename to frontend/app/utils/query-params.js diff --git a/app/utils/serialize-moment.js b/frontend/app/utils/serialize-moment.js similarity index 100% rename from app/utils/serialize-moment.js rename to frontend/app/utils/serialize-moment.js diff --git a/app/utils/url.js b/frontend/app/utils/url.js similarity index 100% rename from app/utils/url.js rename to frontend/app/utils/url.js diff --git a/app/validations/absence-credit.js b/frontend/app/validations/absence-credit.js similarity index 100% rename from app/validations/absence-credit.js rename to frontend/app/validations/absence-credit.js diff --git a/app/validations/absence.js b/frontend/app/validations/absence.js similarity index 100% rename from app/validations/absence.js rename to frontend/app/validations/absence.js diff --git a/app/validations/activity.js b/frontend/app/validations/activity.js similarity index 100% rename from app/validations/activity.js rename to frontend/app/validations/activity.js diff --git a/app/validations/attendance.js b/frontend/app/validations/attendance.js similarity index 100% rename from app/validations/attendance.js rename to frontend/app/validations/attendance.js diff --git a/app/validations/intersection.js b/frontend/app/validations/intersection.js similarity index 100% rename from app/validations/intersection.js rename to frontend/app/validations/intersection.js diff --git a/app/validations/multiple-absence.js b/frontend/app/validations/multiple-absence.js similarity index 100% rename from app/validations/multiple-absence.js rename to frontend/app/validations/multiple-absence.js diff --git a/app/validations/overtime-credit.js b/frontend/app/validations/overtime-credit.js similarity index 100% rename from app/validations/overtime-credit.js rename to frontend/app/validations/overtime-credit.js diff --git a/app/validations/project.js b/frontend/app/validations/project.js similarity index 100% rename from app/validations/project.js rename to frontend/app/validations/project.js diff --git a/app/validations/report.js b/frontend/app/validations/report.js similarity index 100% rename from app/validations/report.js rename to frontend/app/validations/report.js diff --git a/app/validations/task.js b/frontend/app/validations/task.js similarity index 100% rename from app/validations/task.js rename to frontend/app/validations/task.js diff --git a/app/validators/intersection-task.js b/frontend/app/validators/intersection-task.js similarity index 100% rename from app/validators/intersection-task.js rename to frontend/app/validators/intersection-task.js diff --git a/app/validators/moment.js b/frontend/app/validators/moment.js similarity index 100% rename from app/validators/moment.js rename to frontend/app/validators/moment.js diff --git a/app/validators/null-or-not-blank.js b/frontend/app/validators/null-or-not-blank.js similarity index 100% rename from app/validators/null-or-not-blank.js rename to frontend/app/validators/null-or-not-blank.js diff --git a/config/coverage.js b/frontend/config/coverage.js similarity index 100% rename from config/coverage.js rename to frontend/config/coverage.js diff --git a/config/dependency-lint.js b/frontend/config/dependency-lint.js similarity index 100% rename from config/dependency-lint.js rename to frontend/config/dependency-lint.js diff --git a/config/deprecation-workflow.js b/frontend/config/deprecation-workflow.js similarity index 100% rename from config/deprecation-workflow.js rename to frontend/config/deprecation-workflow.js diff --git a/config/ember-cli-update.json b/frontend/config/ember-cli-update.json similarity index 100% rename from config/ember-cli-update.json rename to frontend/config/ember-cli-update.json diff --git a/config/environment.js b/frontend/config/environment.js similarity index 100% rename from config/environment.js rename to frontend/config/environment.js diff --git a/config/icons.js b/frontend/config/icons.js similarity index 100% rename from config/icons.js rename to frontend/config/icons.js diff --git a/config/optional-features.json b/frontend/config/optional-features.json similarity index 100% rename from config/optional-features.json rename to frontend/config/optional-features.json diff --git a/config/targets.js b/frontend/config/targets.js similarity index 100% rename from config/targets.js rename to frontend/config/targets.js diff --git a/contrib/nginx.conf b/frontend/contrib/nginx.conf similarity index 100% rename from contrib/nginx.conf rename to frontend/contrib/nginx.conf diff --git a/docker-compose.yml b/frontend/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to frontend/docker-compose.yml diff --git a/docker-entrypoint.sh b/frontend/docker-entrypoint.sh similarity index 100% rename from docker-entrypoint.sh rename to frontend/docker-entrypoint.sh diff --git a/ember-cli-build.js b/frontend/ember-cli-build.js similarity index 100% rename from ember-cli-build.js rename to frontend/ember-cli-build.js diff --git a/mirage/config.js b/frontend/mirage/config.js similarity index 100% rename from mirage/config.js rename to frontend/mirage/config.js diff --git a/mirage/factories/absence-balance.js b/frontend/mirage/factories/absence-balance.js similarity index 100% rename from mirage/factories/absence-balance.js rename to frontend/mirage/factories/absence-balance.js diff --git a/mirage/factories/absence-credit.js b/frontend/mirage/factories/absence-credit.js similarity index 100% rename from mirage/factories/absence-credit.js rename to frontend/mirage/factories/absence-credit.js diff --git a/mirage/factories/absence-type.js b/frontend/mirage/factories/absence-type.js similarity index 100% rename from mirage/factories/absence-type.js rename to frontend/mirage/factories/absence-type.js diff --git a/mirage/factories/absence.js b/frontend/mirage/factories/absence.js similarity index 100% rename from mirage/factories/absence.js rename to frontend/mirage/factories/absence.js diff --git a/mirage/factories/activity.js b/frontend/mirage/factories/activity.js similarity index 100% rename from mirage/factories/activity.js rename to frontend/mirage/factories/activity.js diff --git a/mirage/factories/attendance.js b/frontend/mirage/factories/attendance.js similarity index 100% rename from mirage/factories/attendance.js rename to frontend/mirage/factories/attendance.js diff --git a/mirage/factories/billing-type.js b/frontend/mirage/factories/billing-type.js similarity index 100% rename from mirage/factories/billing-type.js rename to frontend/mirage/factories/billing-type.js diff --git a/mirage/factories/cost-center.js b/frontend/mirage/factories/cost-center.js similarity index 100% rename from mirage/factories/cost-center.js rename to frontend/mirage/factories/cost-center.js diff --git a/mirage/factories/customer-statistic.js b/frontend/mirage/factories/customer-statistic.js similarity index 100% rename from mirage/factories/customer-statistic.js rename to frontend/mirage/factories/customer-statistic.js diff --git a/mirage/factories/customer.js b/frontend/mirage/factories/customer.js similarity index 100% rename from mirage/factories/customer.js rename to frontend/mirage/factories/customer.js diff --git a/mirage/factories/employment.js b/frontend/mirage/factories/employment.js similarity index 100% rename from mirage/factories/employment.js rename to frontend/mirage/factories/employment.js diff --git a/mirage/factories/location.js b/frontend/mirage/factories/location.js similarity index 100% rename from mirage/factories/location.js rename to frontend/mirage/factories/location.js diff --git a/mirage/factories/month-statistic.js b/frontend/mirage/factories/month-statistic.js similarity index 100% rename from mirage/factories/month-statistic.js rename to frontend/mirage/factories/month-statistic.js diff --git a/mirage/factories/overtime-credit.js b/frontend/mirage/factories/overtime-credit.js similarity index 100% rename from mirage/factories/overtime-credit.js rename to frontend/mirage/factories/overtime-credit.js diff --git a/mirage/factories/project-assignee.js b/frontend/mirage/factories/project-assignee.js similarity index 100% rename from mirage/factories/project-assignee.js rename to frontend/mirage/factories/project-assignee.js diff --git a/mirage/factories/project-statistic.js b/frontend/mirage/factories/project-statistic.js similarity index 100% rename from mirage/factories/project-statistic.js rename to frontend/mirage/factories/project-statistic.js diff --git a/mirage/factories/project.js b/frontend/mirage/factories/project.js similarity index 100% rename from mirage/factories/project.js rename to frontend/mirage/factories/project.js diff --git a/mirage/factories/public-holiday.js b/frontend/mirage/factories/public-holiday.js similarity index 100% rename from mirage/factories/public-holiday.js rename to frontend/mirage/factories/public-holiday.js diff --git a/mirage/factories/report-intersection.js b/frontend/mirage/factories/report-intersection.js similarity index 100% rename from mirage/factories/report-intersection.js rename to frontend/mirage/factories/report-intersection.js diff --git a/mirage/factories/report.js b/frontend/mirage/factories/report.js similarity index 100% rename from mirage/factories/report.js rename to frontend/mirage/factories/report.js diff --git a/mirage/factories/task-statistic.js b/frontend/mirage/factories/task-statistic.js similarity index 100% rename from mirage/factories/task-statistic.js rename to frontend/mirage/factories/task-statistic.js diff --git a/mirage/factories/task.js b/frontend/mirage/factories/task.js similarity index 100% rename from mirage/factories/task.js rename to frontend/mirage/factories/task.js diff --git a/mirage/factories/user-statistic.js b/frontend/mirage/factories/user-statistic.js similarity index 100% rename from mirage/factories/user-statistic.js rename to frontend/mirage/factories/user-statistic.js diff --git a/mirage/factories/user.js b/frontend/mirage/factories/user.js similarity index 100% rename from mirage/factories/user.js rename to frontend/mirage/factories/user.js diff --git a/mirage/factories/worktime-balance.js b/frontend/mirage/factories/worktime-balance.js similarity index 100% rename from mirage/factories/worktime-balance.js rename to frontend/mirage/factories/worktime-balance.js diff --git a/mirage/factories/year-statistic.js b/frontend/mirage/factories/year-statistic.js similarity index 100% rename from mirage/factories/year-statistic.js rename to frontend/mirage/factories/year-statistic.js diff --git a/mirage/fixtures/absence-types.js b/frontend/mirage/fixtures/absence-types.js similarity index 100% rename from mirage/fixtures/absence-types.js rename to frontend/mirage/fixtures/absence-types.js diff --git a/mirage/helpers/duration.js b/frontend/mirage/helpers/duration.js similarity index 100% rename from mirage/helpers/duration.js rename to frontend/mirage/helpers/duration.js diff --git a/mirage/scenarios/default.js b/frontend/mirage/scenarios/default.js similarity index 100% rename from mirage/scenarios/default.js rename to frontend/mirage/scenarios/default.js diff --git a/mirage/serializers/application.js b/frontend/mirage/serializers/application.js similarity index 100% rename from mirage/serializers/application.js rename to frontend/mirage/serializers/application.js diff --git a/package.json b/frontend/package.json similarity index 100% rename from package.json rename to frontend/package.json diff --git a/pnpm-lock.yaml b/frontend/pnpm-lock.yaml similarity index 100% rename from pnpm-lock.yaml rename to frontend/pnpm-lock.yaml diff --git a/public/assets/favicon-16x16.png b/frontend/public/assets/favicon-16x16.png similarity index 100% rename from public/assets/favicon-16x16.png rename to frontend/public/assets/favicon-16x16.png diff --git a/public/assets/favicon-32x32.png b/frontend/public/assets/favicon-32x32.png similarity index 100% rename from public/assets/favicon-32x32.png rename to frontend/public/assets/favicon-32x32.png diff --git a/public/assets/favicon.ico b/frontend/public/assets/favicon.ico similarity index 100% rename from public/assets/favicon.ico rename to frontend/public/assets/favicon.ico diff --git a/public/assets/logo.png b/frontend/public/assets/logo.png similarity index 100% rename from public/assets/logo.png rename to frontend/public/assets/logo.png diff --git a/public/assets/logo.svg b/frontend/public/assets/logo.svg similarity index 100% rename from public/assets/logo.svg rename to frontend/public/assets/logo.svg diff --git a/public/assets/logo_text.png b/frontend/public/assets/logo_text.png similarity index 100% rename from public/assets/logo_text.png rename to frontend/public/assets/logo_text.png diff --git a/public/crossdomain.xml b/frontend/public/crossdomain.xml similarity index 100% rename from public/crossdomain.xml rename to frontend/public/crossdomain.xml diff --git a/public/robots.txt b/frontend/public/robots.txt similarity index 100% rename from public/robots.txt rename to frontend/public/robots.txt diff --git a/renovate.json b/frontend/renovate.json similarity index 100% rename from renovate.json rename to frontend/renovate.json diff --git a/testem.js b/frontend/testem.js similarity index 100% rename from testem.js rename to frontend/testem.js diff --git a/tests/.eslintrc.js b/frontend/tests/.eslintrc.js similarity index 100% rename from tests/.eslintrc.js rename to frontend/tests/.eslintrc.js diff --git a/tests/acceptance/analysis-edit-test.js b/frontend/tests/acceptance/analysis-edit-test.js similarity index 100% rename from tests/acceptance/analysis-edit-test.js rename to frontend/tests/acceptance/analysis-edit-test.js diff --git a/tests/acceptance/analysis-test.js b/frontend/tests/acceptance/analysis-test.js similarity index 100% rename from tests/acceptance/analysis-test.js rename to frontend/tests/acceptance/analysis-test.js diff --git a/tests/acceptance/auth-test.js b/frontend/tests/acceptance/auth-test.js similarity index 100% rename from tests/acceptance/auth-test.js rename to frontend/tests/acceptance/auth-test.js diff --git a/tests/acceptance/external-employee-test.js b/frontend/tests/acceptance/external-employee-test.js similarity index 100% rename from tests/acceptance/external-employee-test.js rename to frontend/tests/acceptance/external-employee-test.js diff --git a/tests/acceptance/index-activities-edit-test.js b/frontend/tests/acceptance/index-activities-edit-test.js similarity index 100% rename from tests/acceptance/index-activities-edit-test.js rename to frontend/tests/acceptance/index-activities-edit-test.js diff --git a/tests/acceptance/index-activities-test.js b/frontend/tests/acceptance/index-activities-test.js similarity index 100% rename from tests/acceptance/index-activities-test.js rename to frontend/tests/acceptance/index-activities-test.js diff --git a/tests/acceptance/index-attendances-test.js b/frontend/tests/acceptance/index-attendances-test.js similarity index 100% rename from tests/acceptance/index-attendances-test.js rename to frontend/tests/acceptance/index-attendances-test.js diff --git a/tests/acceptance/index-reports-test.js b/frontend/tests/acceptance/index-reports-test.js similarity index 100% rename from tests/acceptance/index-reports-test.js rename to frontend/tests/acceptance/index-reports-test.js diff --git a/tests/acceptance/index-test.js b/frontend/tests/acceptance/index-test.js similarity index 100% rename from tests/acceptance/index-test.js rename to frontend/tests/acceptance/index-test.js diff --git a/tests/acceptance/magic-link-test.js b/frontend/tests/acceptance/magic-link-test.js similarity index 100% rename from tests/acceptance/magic-link-test.js rename to frontend/tests/acceptance/magic-link-test.js diff --git a/tests/acceptance/notfound-test.js b/frontend/tests/acceptance/notfound-test.js similarity index 100% rename from tests/acceptance/notfound-test.js rename to frontend/tests/acceptance/notfound-test.js diff --git a/tests/acceptance/project-test.js b/frontend/tests/acceptance/project-test.js similarity index 100% rename from tests/acceptance/project-test.js rename to frontend/tests/acceptance/project-test.js diff --git a/tests/acceptance/statistics-test.js b/frontend/tests/acceptance/statistics-test.js similarity index 100% rename from tests/acceptance/statistics-test.js rename to frontend/tests/acceptance/statistics-test.js diff --git a/tests/acceptance/tour-test.js b/frontend/tests/acceptance/tour-test.js similarity index 100% rename from tests/acceptance/tour-test.js rename to frontend/tests/acceptance/tour-test.js diff --git a/tests/acceptance/users-edit-credits-absence-credit-test.js b/frontend/tests/acceptance/users-edit-credits-absence-credit-test.js similarity index 100% rename from tests/acceptance/users-edit-credits-absence-credit-test.js rename to frontend/tests/acceptance/users-edit-credits-absence-credit-test.js diff --git a/tests/acceptance/users-edit-credits-overtime-credit-test.js b/frontend/tests/acceptance/users-edit-credits-overtime-credit-test.js similarity index 100% rename from tests/acceptance/users-edit-credits-overtime-credit-test.js rename to frontend/tests/acceptance/users-edit-credits-overtime-credit-test.js diff --git a/tests/acceptance/users-edit-credits-test.js b/frontend/tests/acceptance/users-edit-credits-test.js similarity index 100% rename from tests/acceptance/users-edit-credits-test.js rename to frontend/tests/acceptance/users-edit-credits-test.js diff --git a/tests/acceptance/users-edit-responsibilities-test.js b/frontend/tests/acceptance/users-edit-responsibilities-test.js similarity index 100% rename from tests/acceptance/users-edit-responsibilities-test.js rename to frontend/tests/acceptance/users-edit-responsibilities-test.js diff --git a/tests/acceptance/users-edit-test.js b/frontend/tests/acceptance/users-edit-test.js similarity index 100% rename from tests/acceptance/users-edit-test.js rename to frontend/tests/acceptance/users-edit-test.js diff --git a/tests/acceptance/users-test.js b/frontend/tests/acceptance/users-test.js similarity index 100% rename from tests/acceptance/users-test.js rename to frontend/tests/acceptance/users-test.js diff --git a/tests/helpers/index.js b/frontend/tests/helpers/index.js similarity index 100% rename from tests/helpers/index.js rename to frontend/tests/helpers/index.js diff --git a/tests/helpers/responsive.js b/frontend/tests/helpers/responsive.js similarity index 100% rename from tests/helpers/responsive.js rename to frontend/tests/helpers/responsive.js diff --git a/tests/helpers/session-mock.js b/frontend/tests/helpers/session-mock.js similarity index 100% rename from tests/helpers/session-mock.js rename to frontend/tests/helpers/session-mock.js diff --git a/tests/helpers/task-select.js b/frontend/tests/helpers/task-select.js similarity index 100% rename from tests/helpers/task-select.js rename to frontend/tests/helpers/task-select.js diff --git a/tests/helpers/tracking-mock.js b/frontend/tests/helpers/tracking-mock.js similarity index 100% rename from tests/helpers/tracking-mock.js rename to frontend/tests/helpers/tracking-mock.js diff --git a/tests/helpers/user-select.js b/frontend/tests/helpers/user-select.js similarity index 100% rename from tests/helpers/user-select.js rename to frontend/tests/helpers/user-select.js diff --git a/tests/index.html b/frontend/tests/index.html similarity index 100% rename from tests/index.html rename to frontend/tests/index.html diff --git a/tests/integration/components/async-list/component-test.js b/frontend/tests/integration/components/async-list/component-test.js similarity index 100% rename from tests/integration/components/async-list/component-test.js rename to frontend/tests/integration/components/async-list/component-test.js diff --git a/tests/integration/components/attendance-slider/component-test.js b/frontend/tests/integration/components/attendance-slider/component-test.js similarity index 100% rename from tests/integration/components/attendance-slider/component-test.js rename to frontend/tests/integration/components/attendance-slider/component-test.js diff --git a/tests/integration/components/balance-donut/component-test.js b/frontend/tests/integration/components/balance-donut/component-test.js similarity index 100% rename from tests/integration/components/balance-donut/component-test.js rename to frontend/tests/integration/components/balance-donut/component-test.js diff --git a/tests/integration/components/changed-warning/component-test.js b/frontend/tests/integration/components/changed-warning/component-test.js similarity index 100% rename from tests/integration/components/changed-warning/component-test.js rename to frontend/tests/integration/components/changed-warning/component-test.js diff --git a/tests/integration/components/customer-visible-icon/component-test.js b/frontend/tests/integration/components/customer-visible-icon/component-test.js similarity index 100% rename from tests/integration/components/customer-visible-icon/component-test.js rename to frontend/tests/integration/components/customer-visible-icon/component-test.js diff --git a/tests/integration/components/date-buttons/component-test.js b/frontend/tests/integration/components/date-buttons/component-test.js similarity index 100% rename from tests/integration/components/date-buttons/component-test.js rename to frontend/tests/integration/components/date-buttons/component-test.js diff --git a/tests/integration/components/date-navigation/component-test.js b/frontend/tests/integration/components/date-navigation/component-test.js similarity index 100% rename from tests/integration/components/date-navigation/component-test.js rename to frontend/tests/integration/components/date-navigation/component-test.js diff --git a/tests/integration/components/duration-since/component-test.js b/frontend/tests/integration/components/duration-since/component-test.js similarity index 100% rename from tests/integration/components/duration-since/component-test.js rename to frontend/tests/integration/components/duration-since/component-test.js diff --git a/tests/integration/components/filter-sidebar/component-test.js b/frontend/tests/integration/components/filter-sidebar/component-test.js similarity index 100% rename from tests/integration/components/filter-sidebar/component-test.js rename to frontend/tests/integration/components/filter-sidebar/component-test.js diff --git a/tests/integration/components/filter-sidebar/filter/component-test.js b/frontend/tests/integration/components/filter-sidebar/filter/component-test.js similarity index 100% rename from tests/integration/components/filter-sidebar/filter/component-test.js rename to frontend/tests/integration/components/filter-sidebar/filter/component-test.js diff --git a/tests/integration/components/filter-sidebar/group/component-test.js b/frontend/tests/integration/components/filter-sidebar/group/component-test.js similarity index 100% rename from tests/integration/components/filter-sidebar/group/component-test.js rename to frontend/tests/integration/components/filter-sidebar/group/component-test.js diff --git a/tests/integration/components/filter-sidebar/label/component-test.js b/frontend/tests/integration/components/filter-sidebar/label/component-test.js similarity index 100% rename from tests/integration/components/filter-sidebar/label/component-test.js rename to frontend/tests/integration/components/filter-sidebar/label/component-test.js diff --git a/tests/integration/components/in-viewport/component-test.js b/frontend/tests/integration/components/in-viewport/component-test.js similarity index 100% rename from tests/integration/components/in-viewport/component-test.js rename to frontend/tests/integration/components/in-viewport/component-test.js diff --git a/tests/integration/components/loading-icon/component-test.js b/frontend/tests/integration/components/loading-icon/component-test.js similarity index 100% rename from tests/integration/components/loading-icon/component-test.js rename to frontend/tests/integration/components/loading-icon/component-test.js diff --git a/tests/integration/components/no-mobile-message/component-test.js b/frontend/tests/integration/components/no-mobile-message/component-test.js similarity index 100% rename from tests/integration/components/no-mobile-message/component-test.js rename to frontend/tests/integration/components/no-mobile-message/component-test.js diff --git a/tests/integration/components/no-permission/component-test.js b/frontend/tests/integration/components/no-permission/component-test.js similarity index 100% rename from tests/integration/components/no-permission/component-test.js rename to frontend/tests/integration/components/no-permission/component-test.js diff --git a/tests/integration/components/not-identical-warning/component-test.js b/frontend/tests/integration/components/not-identical-warning/component-test.js similarity index 100% rename from tests/integration/components/not-identical-warning/component-test.js rename to frontend/tests/integration/components/not-identical-warning/component-test.js diff --git a/tests/integration/components/optimized-power-select/component-test.js b/frontend/tests/integration/components/optimized-power-select/component-test.js similarity index 100% rename from tests/integration/components/optimized-power-select/component-test.js rename to frontend/tests/integration/components/optimized-power-select/component-test.js diff --git a/tests/integration/components/progress-tooltip/component-test.js b/frontend/tests/integration/components/progress-tooltip/component-test.js similarity index 100% rename from tests/integration/components/progress-tooltip/component-test.js rename to frontend/tests/integration/components/progress-tooltip/component-test.js diff --git a/tests/integration/components/record-button/component-test.js b/frontend/tests/integration/components/record-button/component-test.js similarity index 100% rename from tests/integration/components/record-button/component-test.js rename to frontend/tests/integration/components/record-button/component-test.js diff --git a/tests/integration/components/report-review-warning/component-test.js b/frontend/tests/integration/components/report-review-warning/component-test.js similarity index 100% rename from tests/integration/components/report-review-warning/component-test.js rename to frontend/tests/integration/components/report-review-warning/component-test.js diff --git a/tests/integration/components/report-row/component-test.js b/frontend/tests/integration/components/report-row/component-test.js similarity index 100% rename from tests/integration/components/report-row/component-test.js rename to frontend/tests/integration/components/report-row/component-test.js diff --git a/tests/integration/components/sort-header/component-test.js b/frontend/tests/integration/components/sort-header/component-test.js similarity index 100% rename from tests/integration/components/sort-header/component-test.js rename to frontend/tests/integration/components/sort-header/component-test.js diff --git a/tests/integration/components/statistic-list/bar/component-test.js b/frontend/tests/integration/components/statistic-list/bar/component-test.js similarity index 100% rename from tests/integration/components/statistic-list/bar/component-test.js rename to frontend/tests/integration/components/statistic-list/bar/component-test.js diff --git a/tests/integration/components/statistic-list/column/component-test.js b/frontend/tests/integration/components/statistic-list/column/component-test.js similarity index 100% rename from tests/integration/components/statistic-list/column/component-test.js rename to frontend/tests/integration/components/statistic-list/column/component-test.js diff --git a/tests/integration/components/statistic-list/component-test.js b/frontend/tests/integration/components/statistic-list/component-test.js similarity index 100% rename from tests/integration/components/statistic-list/component-test.js rename to frontend/tests/integration/components/statistic-list/component-test.js diff --git a/tests/integration/components/sy-calendar/component-test.js b/frontend/tests/integration/components/sy-calendar/component-test.js similarity index 100% rename from tests/integration/components/sy-calendar/component-test.js rename to frontend/tests/integration/components/sy-calendar/component-test.js diff --git a/tests/integration/components/sy-checkbox/component-test.js b/frontend/tests/integration/components/sy-checkbox/component-test.js similarity index 100% rename from tests/integration/components/sy-checkbox/component-test.js rename to frontend/tests/integration/components/sy-checkbox/component-test.js diff --git a/tests/integration/components/sy-checkmark/component-test.js b/frontend/tests/integration/components/sy-checkmark/component-test.js similarity index 100% rename from tests/integration/components/sy-checkmark/component-test.js rename to frontend/tests/integration/components/sy-checkmark/component-test.js diff --git a/tests/integration/components/sy-datepicker-btn/component-test.js b/frontend/tests/integration/components/sy-datepicker-btn/component-test.js similarity index 100% rename from tests/integration/components/sy-datepicker-btn/component-test.js rename to frontend/tests/integration/components/sy-datepicker-btn/component-test.js diff --git a/tests/integration/components/sy-datepicker/component-test.js b/frontend/tests/integration/components/sy-datepicker/component-test.js similarity index 100% rename from tests/integration/components/sy-datepicker/component-test.js rename to frontend/tests/integration/components/sy-datepicker/component-test.js diff --git a/tests/integration/components/sy-durationpicker-day/component-test.js b/frontend/tests/integration/components/sy-durationpicker-day/component-test.js similarity index 100% rename from tests/integration/components/sy-durationpicker-day/component-test.js rename to frontend/tests/integration/components/sy-durationpicker-day/component-test.js diff --git a/tests/integration/components/sy-durationpicker/component-test.js b/frontend/tests/integration/components/sy-durationpicker/component-test.js similarity index 100% rename from tests/integration/components/sy-durationpicker/component-test.js rename to frontend/tests/integration/components/sy-durationpicker/component-test.js diff --git a/tests/integration/components/sy-modal-target/component-test.js b/frontend/tests/integration/components/sy-modal-target/component-test.js similarity index 100% rename from tests/integration/components/sy-modal-target/component-test.js rename to frontend/tests/integration/components/sy-modal-target/component-test.js diff --git a/tests/integration/components/sy-modal/body/component-test.js b/frontend/tests/integration/components/sy-modal/body/component-test.js similarity index 100% rename from tests/integration/components/sy-modal/body/component-test.js rename to frontend/tests/integration/components/sy-modal/body/component-test.js diff --git a/tests/integration/components/sy-modal/component-test.js b/frontend/tests/integration/components/sy-modal/component-test.js similarity index 100% rename from tests/integration/components/sy-modal/component-test.js rename to frontend/tests/integration/components/sy-modal/component-test.js diff --git a/tests/integration/components/sy-modal/footer/component-test.js b/frontend/tests/integration/components/sy-modal/footer/component-test.js similarity index 100% rename from tests/integration/components/sy-modal/footer/component-test.js rename to frontend/tests/integration/components/sy-modal/footer/component-test.js diff --git a/tests/integration/components/sy-modal/header/component-test.js b/frontend/tests/integration/components/sy-modal/header/component-test.js similarity index 100% rename from tests/integration/components/sy-modal/header/component-test.js rename to frontend/tests/integration/components/sy-modal/header/component-test.js diff --git a/tests/integration/components/sy-modal/overlay/component-test.js b/frontend/tests/integration/components/sy-modal/overlay/component-test.js similarity index 100% rename from tests/integration/components/sy-modal/overlay/component-test.js rename to frontend/tests/integration/components/sy-modal/overlay/component-test.js diff --git a/tests/integration/components/sy-timepicker/component-test.js b/frontend/tests/integration/components/sy-timepicker/component-test.js similarity index 100% rename from tests/integration/components/sy-timepicker/component-test.js rename to frontend/tests/integration/components/sy-timepicker/component-test.js diff --git a/tests/integration/components/sy-toggle/component-test.js b/frontend/tests/integration/components/sy-toggle/component-test.js similarity index 100% rename from tests/integration/components/sy-toggle/component-test.js rename to frontend/tests/integration/components/sy-toggle/component-test.js diff --git a/tests/integration/components/sy-topnav/component-test.js b/frontend/tests/integration/components/sy-topnav/component-test.js similarity index 100% rename from tests/integration/components/sy-topnav/component-test.js rename to frontend/tests/integration/components/sy-topnav/component-test.js diff --git a/tests/integration/components/task-selection/component-test.js b/frontend/tests/integration/components/task-selection/component-test.js similarity index 100% rename from tests/integration/components/task-selection/component-test.js rename to frontend/tests/integration/components/task-selection/component-test.js diff --git a/tests/integration/components/timed-clock/component-test.js b/frontend/tests/integration/components/timed-clock/component-test.js similarity index 100% rename from tests/integration/components/timed-clock/component-test.js rename to frontend/tests/integration/components/timed-clock/component-test.js diff --git a/tests/integration/components/tracking-bar/component-test.js b/frontend/tests/integration/components/tracking-bar/component-test.js similarity index 100% rename from tests/integration/components/tracking-bar/component-test.js rename to frontend/tests/integration/components/tracking-bar/component-test.js diff --git a/tests/integration/components/user-selection/component-test.js b/frontend/tests/integration/components/user-selection/component-test.js similarity index 100% rename from tests/integration/components/user-selection/component-test.js rename to frontend/tests/integration/components/user-selection/component-test.js diff --git a/tests/integration/components/weekly-overview-benchmark/component-test.js b/frontend/tests/integration/components/weekly-overview-benchmark/component-test.js similarity index 100% rename from tests/integration/components/weekly-overview-benchmark/component-test.js rename to frontend/tests/integration/components/weekly-overview-benchmark/component-test.js diff --git a/tests/integration/components/weekly-overview-day/component-test.js b/frontend/tests/integration/components/weekly-overview-day/component-test.js similarity index 100% rename from tests/integration/components/weekly-overview-day/component-test.js rename to frontend/tests/integration/components/weekly-overview-day/component-test.js diff --git a/tests/integration/components/weekly-overview/component-test.js b/frontend/tests/integration/components/weekly-overview/component-test.js similarity index 100% rename from tests/integration/components/weekly-overview/component-test.js rename to frontend/tests/integration/components/weekly-overview/component-test.js diff --git a/tests/integration/components/welcome-modal/component-test.js b/frontend/tests/integration/components/welcome-modal/component-test.js similarity index 100% rename from tests/integration/components/welcome-modal/component-test.js rename to frontend/tests/integration/components/welcome-modal/component-test.js diff --git a/tests/integration/components/worktime-balance-chart/component-test.js b/frontend/tests/integration/components/worktime-balance-chart/component-test.js similarity index 100% rename from tests/integration/components/worktime-balance-chart/component-test.js rename to frontend/tests/integration/components/worktime-balance-chart/component-test.js diff --git a/tests/test-helper.js b/frontend/tests/test-helper.js similarity index 100% rename from tests/test-helper.js rename to frontend/tests/test-helper.js diff --git a/tests/unit/abilities/report-test.js b/frontend/tests/unit/abilities/report-test.js similarity index 100% rename from tests/unit/abilities/report-test.js rename to frontend/tests/unit/abilities/report-test.js diff --git a/tests/unit/analysis/edit/controller-test.js b/frontend/tests/unit/analysis/edit/controller-test.js similarity index 100% rename from tests/unit/analysis/edit/controller-test.js rename to frontend/tests/unit/analysis/edit/controller-test.js diff --git a/tests/unit/analysis/edit/route-test.js b/frontend/tests/unit/analysis/edit/route-test.js similarity index 100% rename from tests/unit/analysis/edit/route-test.js rename to frontend/tests/unit/analysis/edit/route-test.js diff --git a/tests/unit/analysis/index/controller-test.js b/frontend/tests/unit/analysis/index/controller-test.js similarity index 100% rename from tests/unit/analysis/index/controller-test.js rename to frontend/tests/unit/analysis/index/controller-test.js diff --git a/tests/unit/analysis/index/route-test.js b/frontend/tests/unit/analysis/index/route-test.js similarity index 100% rename from tests/unit/analysis/index/route-test.js rename to frontend/tests/unit/analysis/index/route-test.js diff --git a/tests/unit/analysis/route-test.js b/frontend/tests/unit/analysis/route-test.js similarity index 100% rename from tests/unit/analysis/route-test.js rename to frontend/tests/unit/analysis/route-test.js diff --git a/tests/unit/controllers/qpcontroller/controller-test.js b/frontend/tests/unit/controllers/qpcontroller/controller-test.js similarity index 100% rename from tests/unit/controllers/qpcontroller/controller-test.js rename to frontend/tests/unit/controllers/qpcontroller/controller-test.js diff --git a/tests/unit/helpers/balance-highlight-class-test.js b/frontend/tests/unit/helpers/balance-highlight-class-test.js similarity index 100% rename from tests/unit/helpers/balance-highlight-class-test.js rename to frontend/tests/unit/helpers/balance-highlight-class-test.js diff --git a/tests/unit/helpers/format-duration-test.js b/frontend/tests/unit/helpers/format-duration-test.js similarity index 100% rename from tests/unit/helpers/format-duration-test.js rename to frontend/tests/unit/helpers/format-duration-test.js diff --git a/tests/unit/helpers/humanize-duration-test.js b/frontend/tests/unit/helpers/humanize-duration-test.js similarity index 100% rename from tests/unit/helpers/humanize-duration-test.js rename to frontend/tests/unit/helpers/humanize-duration-test.js diff --git a/tests/unit/helpers/parse-django-duration-test.js b/frontend/tests/unit/helpers/parse-django-duration-test.js similarity index 100% rename from tests/unit/helpers/parse-django-duration-test.js rename to frontend/tests/unit/helpers/parse-django-duration-test.js diff --git a/tests/unit/index/activities/controller-test.js b/frontend/tests/unit/index/activities/controller-test.js similarity index 100% rename from tests/unit/index/activities/controller-test.js rename to frontend/tests/unit/index/activities/controller-test.js diff --git a/tests/unit/index/activities/edit/controller-test.js b/frontend/tests/unit/index/activities/edit/controller-test.js similarity index 100% rename from tests/unit/index/activities/edit/controller-test.js rename to frontend/tests/unit/index/activities/edit/controller-test.js diff --git a/tests/unit/index/activities/edit/route-test.js b/frontend/tests/unit/index/activities/edit/route-test.js similarity index 100% rename from tests/unit/index/activities/edit/route-test.js rename to frontend/tests/unit/index/activities/edit/route-test.js diff --git a/tests/unit/index/activities/route-test.js b/frontend/tests/unit/index/activities/route-test.js similarity index 100% rename from tests/unit/index/activities/route-test.js rename to frontend/tests/unit/index/activities/route-test.js diff --git a/tests/unit/index/attendances/controller-test.js b/frontend/tests/unit/index/attendances/controller-test.js similarity index 100% rename from tests/unit/index/attendances/controller-test.js rename to frontend/tests/unit/index/attendances/controller-test.js diff --git a/tests/unit/index/attendances/route-test.js b/frontend/tests/unit/index/attendances/route-test.js similarity index 100% rename from tests/unit/index/attendances/route-test.js rename to frontend/tests/unit/index/attendances/route-test.js diff --git a/tests/unit/index/controller-test.js b/frontend/tests/unit/index/controller-test.js similarity index 100% rename from tests/unit/index/controller-test.js rename to frontend/tests/unit/index/controller-test.js diff --git a/tests/unit/index/reports/controller-test.js b/frontend/tests/unit/index/reports/controller-test.js similarity index 100% rename from tests/unit/index/reports/controller-test.js rename to frontend/tests/unit/index/reports/controller-test.js diff --git a/tests/unit/index/reports/route-test.js b/frontend/tests/unit/index/reports/route-test.js similarity index 100% rename from tests/unit/index/reports/route-test.js rename to frontend/tests/unit/index/reports/route-test.js diff --git a/tests/unit/index/route-test.js b/frontend/tests/unit/index/route-test.js similarity index 100% rename from tests/unit/index/route-test.js rename to frontend/tests/unit/index/route-test.js diff --git a/tests/unit/login/route-test.js b/frontend/tests/unit/login/route-test.js similarity index 100% rename from tests/unit/login/route-test.js rename to frontend/tests/unit/login/route-test.js diff --git a/tests/unit/models/absence-balance-test.js b/frontend/tests/unit/models/absence-balance-test.js similarity index 100% rename from tests/unit/models/absence-balance-test.js rename to frontend/tests/unit/models/absence-balance-test.js diff --git a/tests/unit/models/activity-test.js b/frontend/tests/unit/models/activity-test.js similarity index 100% rename from tests/unit/models/activity-test.js rename to frontend/tests/unit/models/activity-test.js diff --git a/tests/unit/models/attendance-test.js b/frontend/tests/unit/models/attendance-test.js similarity index 100% rename from tests/unit/models/attendance-test.js rename to frontend/tests/unit/models/attendance-test.js diff --git a/tests/unit/models/billing-type-test.js b/frontend/tests/unit/models/billing-type-test.js similarity index 100% rename from tests/unit/models/billing-type-test.js rename to frontend/tests/unit/models/billing-type-test.js diff --git a/tests/unit/models/cost-center-test.js b/frontend/tests/unit/models/cost-center-test.js similarity index 100% rename from tests/unit/models/cost-center-test.js rename to frontend/tests/unit/models/cost-center-test.js diff --git a/tests/unit/models/customer-statistic-test.js b/frontend/tests/unit/models/customer-statistic-test.js similarity index 100% rename from tests/unit/models/customer-statistic-test.js rename to frontend/tests/unit/models/customer-statistic-test.js diff --git a/tests/unit/models/customer-test.js b/frontend/tests/unit/models/customer-test.js similarity index 100% rename from tests/unit/models/customer-test.js rename to frontend/tests/unit/models/customer-test.js diff --git a/tests/unit/models/employment-test.js b/frontend/tests/unit/models/employment-test.js similarity index 100% rename from tests/unit/models/employment-test.js rename to frontend/tests/unit/models/employment-test.js diff --git a/tests/unit/models/location-test.js b/frontend/tests/unit/models/location-test.js similarity index 100% rename from tests/unit/models/location-test.js rename to frontend/tests/unit/models/location-test.js diff --git a/tests/unit/models/month-statistic-test.js b/frontend/tests/unit/models/month-statistic-test.js similarity index 100% rename from tests/unit/models/month-statistic-test.js rename to frontend/tests/unit/models/month-statistic-test.js diff --git a/tests/unit/models/overtime-credit-test.js b/frontend/tests/unit/models/overtime-credit-test.js similarity index 100% rename from tests/unit/models/overtime-credit-test.js rename to frontend/tests/unit/models/overtime-credit-test.js diff --git a/tests/unit/models/project-statistic-test.js b/frontend/tests/unit/models/project-statistic-test.js similarity index 100% rename from tests/unit/models/project-statistic-test.js rename to frontend/tests/unit/models/project-statistic-test.js diff --git a/tests/unit/models/project-test.js b/frontend/tests/unit/models/project-test.js similarity index 100% rename from tests/unit/models/project-test.js rename to frontend/tests/unit/models/project-test.js diff --git a/tests/unit/models/public-holiday-test.js b/frontend/tests/unit/models/public-holiday-test.js similarity index 100% rename from tests/unit/models/public-holiday-test.js rename to frontend/tests/unit/models/public-holiday-test.js diff --git a/tests/unit/models/report-intersection-test.js b/frontend/tests/unit/models/report-intersection-test.js similarity index 100% rename from tests/unit/models/report-intersection-test.js rename to frontend/tests/unit/models/report-intersection-test.js diff --git a/tests/unit/models/report-test.js b/frontend/tests/unit/models/report-test.js similarity index 100% rename from tests/unit/models/report-test.js rename to frontend/tests/unit/models/report-test.js diff --git a/tests/unit/models/task-statistic-test.js b/frontend/tests/unit/models/task-statistic-test.js similarity index 100% rename from tests/unit/models/task-statistic-test.js rename to frontend/tests/unit/models/task-statistic-test.js diff --git a/tests/unit/models/task-test.js b/frontend/tests/unit/models/task-test.js similarity index 100% rename from tests/unit/models/task-test.js rename to frontend/tests/unit/models/task-test.js diff --git a/tests/unit/models/user-statistic-test.js b/frontend/tests/unit/models/user-statistic-test.js similarity index 100% rename from tests/unit/models/user-statistic-test.js rename to frontend/tests/unit/models/user-statistic-test.js diff --git a/tests/unit/models/user-test.js b/frontend/tests/unit/models/user-test.js similarity index 100% rename from tests/unit/models/user-test.js rename to frontend/tests/unit/models/user-test.js diff --git a/tests/unit/models/worktime-balance-test.js b/frontend/tests/unit/models/worktime-balance-test.js similarity index 100% rename from tests/unit/models/worktime-balance-test.js rename to frontend/tests/unit/models/worktime-balance-test.js diff --git a/tests/unit/models/year-statistic-test.js b/frontend/tests/unit/models/year-statistic-test.js similarity index 100% rename from tests/unit/models/year-statistic-test.js rename to frontend/tests/unit/models/year-statistic-test.js diff --git a/tests/unit/no-access/route-test.js b/frontend/tests/unit/no-access/route-test.js similarity index 100% rename from tests/unit/no-access/route-test.js rename to frontend/tests/unit/no-access/route-test.js diff --git a/tests/unit/notfound/route-test.js b/frontend/tests/unit/notfound/route-test.js similarity index 100% rename from tests/unit/notfound/route-test.js rename to frontend/tests/unit/notfound/route-test.js diff --git a/tests/unit/projects/controller-test.js b/frontend/tests/unit/projects/controller-test.js similarity index 100% rename from tests/unit/projects/controller-test.js rename to frontend/tests/unit/projects/controller-test.js diff --git a/tests/unit/projects/route-test.js b/frontend/tests/unit/projects/route-test.js similarity index 100% rename from tests/unit/projects/route-test.js rename to frontend/tests/unit/projects/route-test.js diff --git a/tests/unit/protected/controller-test.js b/frontend/tests/unit/protected/controller-test.js similarity index 100% rename from tests/unit/protected/controller-test.js rename to frontend/tests/unit/protected/controller-test.js diff --git a/tests/unit/protected/route-test.js b/frontend/tests/unit/protected/route-test.js similarity index 100% rename from tests/unit/protected/route-test.js rename to frontend/tests/unit/protected/route-test.js diff --git a/tests/unit/serializers/attendance-test.js b/frontend/tests/unit/serializers/attendance-test.js similarity index 100% rename from tests/unit/serializers/attendance-test.js rename to frontend/tests/unit/serializers/attendance-test.js diff --git a/tests/unit/serializers/employment-test.js b/frontend/tests/unit/serializers/employment-test.js similarity index 100% rename from tests/unit/serializers/employment-test.js rename to frontend/tests/unit/serializers/employment-test.js diff --git a/tests/unit/services/autostart-tour-test.js b/frontend/tests/unit/services/autostart-tour-test.js similarity index 100% rename from tests/unit/services/autostart-tour-test.js rename to frontend/tests/unit/services/autostart-tour-test.js diff --git a/tests/unit/services/fetch-test.js b/frontend/tests/unit/services/fetch-test.js similarity index 100% rename from tests/unit/services/fetch-test.js rename to frontend/tests/unit/services/fetch-test.js diff --git a/tests/unit/services/metadata-fetcher-test.js b/frontend/tests/unit/services/metadata-fetcher-test.js similarity index 100% rename from tests/unit/services/metadata-fetcher-test.js rename to frontend/tests/unit/services/metadata-fetcher-test.js diff --git a/tests/unit/services/rejected-reports-test.js b/frontend/tests/unit/services/rejected-reports-test.js similarity index 100% rename from tests/unit/services/rejected-reports-test.js rename to frontend/tests/unit/services/rejected-reports-test.js diff --git a/tests/unit/services/tracking-test.js b/frontend/tests/unit/services/tracking-test.js similarity index 100% rename from tests/unit/services/tracking-test.js rename to frontend/tests/unit/services/tracking-test.js diff --git a/tests/unit/services/unverified-reports-test.js b/frontend/tests/unit/services/unverified-reports-test.js similarity index 100% rename from tests/unit/services/unverified-reports-test.js rename to frontend/tests/unit/services/unverified-reports-test.js diff --git a/tests/unit/sso-login/route-test.js b/frontend/tests/unit/sso-login/route-test.js similarity index 100% rename from tests/unit/sso-login/route-test.js rename to frontend/tests/unit/sso-login/route-test.js diff --git a/tests/unit/statistics/controller-test.js b/frontend/tests/unit/statistics/controller-test.js similarity index 100% rename from tests/unit/statistics/controller-test.js rename to frontend/tests/unit/statistics/controller-test.js diff --git a/tests/unit/statistics/route-test.js b/frontend/tests/unit/statistics/route-test.js similarity index 100% rename from tests/unit/statistics/route-test.js rename to frontend/tests/unit/statistics/route-test.js diff --git a/tests/unit/transforms/django-date-test.js b/frontend/tests/unit/transforms/django-date-test.js similarity index 100% rename from tests/unit/transforms/django-date-test.js rename to frontend/tests/unit/transforms/django-date-test.js diff --git a/tests/unit/transforms/django-datetime-test.js b/frontend/tests/unit/transforms/django-datetime-test.js similarity index 100% rename from tests/unit/transforms/django-datetime-test.js rename to frontend/tests/unit/transforms/django-datetime-test.js diff --git a/tests/unit/transforms/django-duration-test.js b/frontend/tests/unit/transforms/django-duration-test.js similarity index 100% rename from tests/unit/transforms/django-duration-test.js rename to frontend/tests/unit/transforms/django-duration-test.js diff --git a/tests/unit/transforms/django-time-test.js b/frontend/tests/unit/transforms/django-time-test.js similarity index 100% rename from tests/unit/transforms/django-time-test.js rename to frontend/tests/unit/transforms/django-time-test.js diff --git a/tests/unit/transforms/django-workdays-test.js b/frontend/tests/unit/transforms/django-workdays-test.js similarity index 100% rename from tests/unit/transforms/django-workdays-test.js rename to frontend/tests/unit/transforms/django-workdays-test.js diff --git a/tests/unit/users/edit/controller-test.js b/frontend/tests/unit/users/edit/controller-test.js similarity index 100% rename from tests/unit/users/edit/controller-test.js rename to frontend/tests/unit/users/edit/controller-test.js diff --git a/tests/unit/users/edit/credits/absence-credits/edit/controller-test.js b/frontend/tests/unit/users/edit/credits/absence-credits/edit/controller-test.js similarity index 100% rename from tests/unit/users/edit/credits/absence-credits/edit/controller-test.js rename to frontend/tests/unit/users/edit/credits/absence-credits/edit/controller-test.js diff --git a/tests/unit/users/edit/credits/absence-credits/edit/route-test.js b/frontend/tests/unit/users/edit/credits/absence-credits/edit/route-test.js similarity index 100% rename from tests/unit/users/edit/credits/absence-credits/edit/route-test.js rename to frontend/tests/unit/users/edit/credits/absence-credits/edit/route-test.js diff --git a/tests/unit/users/edit/credits/absence-credits/new/route-test.js b/frontend/tests/unit/users/edit/credits/absence-credits/new/route-test.js similarity index 100% rename from tests/unit/users/edit/credits/absence-credits/new/route-test.js rename to frontend/tests/unit/users/edit/credits/absence-credits/new/route-test.js diff --git a/tests/unit/users/edit/credits/index/controller-test.js b/frontend/tests/unit/users/edit/credits/index/controller-test.js similarity index 100% rename from tests/unit/users/edit/credits/index/controller-test.js rename to frontend/tests/unit/users/edit/credits/index/controller-test.js diff --git a/tests/unit/users/edit/credits/index/route-test.js b/frontend/tests/unit/users/edit/credits/index/route-test.js similarity index 100% rename from tests/unit/users/edit/credits/index/route-test.js rename to frontend/tests/unit/users/edit/credits/index/route-test.js diff --git a/tests/unit/users/edit/credits/overtime-credits/edit/controller-test.js b/frontend/tests/unit/users/edit/credits/overtime-credits/edit/controller-test.js similarity index 100% rename from tests/unit/users/edit/credits/overtime-credits/edit/controller-test.js rename to frontend/tests/unit/users/edit/credits/overtime-credits/edit/controller-test.js diff --git a/tests/unit/users/edit/credits/overtime-credits/edit/route-test.js b/frontend/tests/unit/users/edit/credits/overtime-credits/edit/route-test.js similarity index 100% rename from tests/unit/users/edit/credits/overtime-credits/edit/route-test.js rename to frontend/tests/unit/users/edit/credits/overtime-credits/edit/route-test.js diff --git a/tests/unit/users/edit/credits/overtime-credits/new/route-test.js b/frontend/tests/unit/users/edit/credits/overtime-credits/new/route-test.js similarity index 100% rename from tests/unit/users/edit/credits/overtime-credits/new/route-test.js rename to frontend/tests/unit/users/edit/credits/overtime-credits/new/route-test.js diff --git a/tests/unit/users/edit/credits/route-test.js b/frontend/tests/unit/users/edit/credits/route-test.js similarity index 100% rename from tests/unit/users/edit/credits/route-test.js rename to frontend/tests/unit/users/edit/credits/route-test.js diff --git a/tests/unit/users/edit/index/controller-test.js b/frontend/tests/unit/users/edit/index/controller-test.js similarity index 100% rename from tests/unit/users/edit/index/controller-test.js rename to frontend/tests/unit/users/edit/index/controller-test.js diff --git a/tests/unit/users/edit/index/route-test.js b/frontend/tests/unit/users/edit/index/route-test.js similarity index 100% rename from tests/unit/users/edit/index/route-test.js rename to frontend/tests/unit/users/edit/index/route-test.js diff --git a/tests/unit/users/edit/responsibilities/controller-test.js b/frontend/tests/unit/users/edit/responsibilities/controller-test.js similarity index 100% rename from tests/unit/users/edit/responsibilities/controller-test.js rename to frontend/tests/unit/users/edit/responsibilities/controller-test.js diff --git a/tests/unit/users/edit/responsibilities/route-test.js b/frontend/tests/unit/users/edit/responsibilities/route-test.js similarity index 100% rename from tests/unit/users/edit/responsibilities/route-test.js rename to frontend/tests/unit/users/edit/responsibilities/route-test.js diff --git a/tests/unit/users/edit/route-test.js b/frontend/tests/unit/users/edit/route-test.js similarity index 100% rename from tests/unit/users/edit/route-test.js rename to frontend/tests/unit/users/edit/route-test.js diff --git a/tests/unit/users/index/controller-test.js b/frontend/tests/unit/users/index/controller-test.js similarity index 100% rename from tests/unit/users/index/controller-test.js rename to frontend/tests/unit/users/index/controller-test.js diff --git a/tests/unit/users/index/route-test.js b/frontend/tests/unit/users/index/route-test.js similarity index 100% rename from tests/unit/users/index/route-test.js rename to frontend/tests/unit/users/index/route-test.js diff --git a/tests/unit/users/route-test.js b/frontend/tests/unit/users/route-test.js similarity index 100% rename from tests/unit/users/route-test.js rename to frontend/tests/unit/users/route-test.js diff --git a/tests/unit/utils/format-duration-test.js b/frontend/tests/unit/utils/format-duration-test.js similarity index 100% rename from tests/unit/utils/format-duration-test.js rename to frontend/tests/unit/utils/format-duration-test.js diff --git a/tests/unit/utils/humanize-duration-test.js b/frontend/tests/unit/utils/humanize-duration-test.js similarity index 100% rename from tests/unit/utils/humanize-duration-test.js rename to frontend/tests/unit/utils/humanize-duration-test.js diff --git a/tests/unit/utils/parse-django-duration-test.js b/frontend/tests/unit/utils/parse-django-duration-test.js similarity index 100% rename from tests/unit/utils/parse-django-duration-test.js rename to frontend/tests/unit/utils/parse-django-duration-test.js diff --git a/tests/unit/utils/query-params-test.js b/frontend/tests/unit/utils/query-params-test.js similarity index 100% rename from tests/unit/utils/query-params-test.js rename to frontend/tests/unit/utils/query-params-test.js diff --git a/tests/unit/utils/url-test.js b/frontend/tests/unit/utils/url-test.js similarity index 100% rename from tests/unit/utils/url-test.js rename to frontend/tests/unit/utils/url-test.js diff --git a/tests/unit/validators/moment-test.js b/frontend/tests/unit/validators/moment-test.js similarity index 100% rename from tests/unit/validators/moment-test.js rename to frontend/tests/unit/validators/moment-test.js diff --git a/tests/unit/validators/null-or-not-blank-test.js b/frontend/tests/unit/validators/null-or-not-blank-test.js similarity index 100% rename from tests/unit/validators/null-or-not-blank-test.js rename to frontend/tests/unit/validators/null-or-not-blank-test.js diff --git a/vendor/.gitkeep b/frontend/vendor/.gitkeep similarity index 100% rename from vendor/.gitkeep rename to frontend/vendor/.gitkeep From 22c5ba2620c36c54cf4e355674c2a3bf8acf23f4 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 29 Nov 2023 10:50:06 +0100 Subject: [PATCH 979/980] chore: move backend files into backend subdirectory This is part of merging timed-backend and timed-frontend into one repository. --- .dockerignore => backend/.dockerignore | 0 .editorconfig => backend/.editorconfig | 0 .flake8 => backend/.flake8 | 0 {.github => backend/.github}/dependabot.yml | 0 {.github => backend/.github}/workflows/release.yaml | 0 {.github => backend/.github}/workflows/test.yml | 0 .gitignore => backend/.gitignore | 0 .../.pre-commit-config.yaml | 0 CHANGELOG.md => backend/CHANGELOG.md | 0 CODEOWNERS => backend/CODEOWNERS | 0 CONTRIBUTING.md => backend/CONTRIBUTING.md | 0 Dockerfile => backend/Dockerfile | 0 LICENSE => backend/LICENSE | 0 Makefile => backend/Makefile | 0 README.md => backend/README.md | 0 cmd.sh => backend/cmd.sh | 0 .../dev-config}/keycloak-config.json | 0 {dev-config => backend/dev-config}/nginx.conf | 0 .../docker-compose.override.yml | 0 docker-compose.yml => backend/docker-compose.yml | 0 manage.py => backend/manage.py | 0 poetry.lock => backend/poetry.lock | 0 pyproject.toml => backend/pyproject.toml | 0 {timed => backend/timed}/.gitignore | 0 {timed => backend/timed}/__init__.py | 0 {timed => backend/timed}/admin.py | 0 {timed => backend/timed}/apps.py | 0 {timed => backend/timed}/authentication.py | 0 {timed => backend/timed}/conftest.py | 0 {timed => backend/timed}/employment/__init__.py | 0 {timed => backend/timed}/employment/admin.py | 0 {timed => backend/timed}/employment/apps.py | 0 {timed => backend/timed}/employment/factories.py | 0 {timed => backend/timed}/employment/filters.py | 0 .../timed}/employment/migrations/0001_initial.py | 0 .../migrations/0002_auto_20170823_1051.py | 0 .../employment/migrations/0003_user_tour_done.py | 0 .../migrations/0004_auto_20170904_1510.py | 0 .../migrations/0005_auto_20170906_1259.py | 0 .../migrations/0006_auto_20170906_1635.py | 0 .../migrations/0007_auto_20170911_0959.py | 0 .../migrations/0008_auto_20171013_1041.py | 0 .../migrations/0009_delete_userabsencetype.py | 0 .../migrations/0010_overtimecredit_comment.py | 0 .../migrations/0011_auto_20171101_1227.py | 0 .../migrations/0012_auto_20181026_1528.py | 0 .../migrations/0013_auto_20210302_1136.py | 0 .../migrations/0014_employment_is_external.py | 0 .../migrations/0015_user_is_accountant.py | 0 .../timed}/employment/migrations/__init__.py | 0 {timed => backend/timed}/employment/models.py | 0 {timed => backend/timed}/employment/permissions.py | 0 {timed => backend/timed}/employment/relations.py | 0 {timed => backend/timed}/employment/serializers.py | 0 .../timed}/employment/tests/__init__.py | 0 .../timed}/employment/tests/test_absence_balance.py | 0 .../timed}/employment/tests/test_absence_credit.py | 0 .../timed}/employment/tests/test_absence_type.py | 0 .../timed}/employment/tests/test_employment.py | 0 .../timed}/employment/tests/test_location.py | 0 .../timed}/employment/tests/test_overtime_credit.py | 0 .../timed}/employment/tests/test_public_holiday.py | 0 .../timed}/employment/tests/test_user.py | 0 .../employment/tests/test_worktime_balance.py | 0 {timed => backend/timed}/employment/urls.py | 0 {timed => backend/timed}/employment/views.py | 0 {timed => backend/timed}/fixtures/test_data.json | 0 {timed => backend/timed}/forms.py | 0 .../timed}/locale/en/LC_MESSAGES/django.po | 0 {timed => backend/timed}/mixins.py | 0 {timed => backend/timed}/models.py | 0 {timed => backend/timed}/notifications/__init__.py | 0 {timed => backend/timed}/notifications/factories.py | 0 .../management/commands/budget_check.py | 0 .../commands/notify_changed_employments.py | 0 .../commands/notify_reviewers_unverified.py | 0 .../commands/notify_supervisors_shorttime.py | 0 .../timed}/notifications/migrations/0001_initial.py | 0 .../0002_alter_notification_notification_type.py | 0 .../timed}/notifications/migrations/__init__.py | 0 {timed => backend/timed}/notifications/models.py | 0 .../timed}/notifications/notify_admin.py | 0 .../notifications/templates/budget_reminder.txt | 0 .../templates/mail/notify_changed_employments.txt | 0 .../templates/mail/notify_reviewers_unverified.txt | 0 .../templates/mail/notify_supervisor_shorttime.txt | 0 .../timed}/notifications/tests/test_budget_check.py | 0 .../tests/test_notify_changed_employments.py | 0 .../tests/test_notify_reviewers_unverified.py | 0 .../tests/test_notify_supervisors_shorttime.py | 0 {timed => backend/timed}/permissions.py | 0 {timed => backend/timed}/projects/__init__.py | 0 {timed => backend/timed}/projects/admin.py | 0 {timed => backend/timed}/projects/apps.py | 0 {timed => backend/timed}/projects/factories.py | 0 {timed => backend/timed}/projects/filters.py | 0 .../timed}/projects/migrations/0001_initial.py | 0 .../projects/migrations/0002_auto_20170823_1045.py | 0 .../projects/migrations/0003_auto_20170831_1624.py | 0 .../projects/migrations/0004_auto_20170906_1045.py | 0 .../projects/migrations/0005_auto_20170907_0938.py | 0 .../projects/migrations/0006_auto_20171010_1423.py | 0 .../migrations/0007_project_subscription_project.py | 0 .../projects/migrations/0008_auto_20190220_1133.py | 0 .../projects/migrations/0009_auto_20201201_1412.py | 0 .../projects/migrations/0010_project_billed.py | 0 .../projects/migrations/0011_auto_20210419_1459.py | 0 .../0012_migrate_reviewers_to_assignees.py | 0 .../migrations/0013_remove_project_reviewers.py | 0 .../0014_add_is_customer_role_to_assignees.py | 0 .../0015_remaining_effort_task_project.py | 0 .../timed}/projects/migrations/__init__.py | 0 {timed => backend/timed}/projects/models.py | 0 {timed => backend/timed}/projects/serializers.py | 0 {timed => backend/timed}/projects/tests/__init__.py | 0 .../timed}/projects/tests/test_billing_type.py | 0 .../timed}/projects/tests/test_cost_center.py | 0 .../timed}/projects/tests/test_customer.py | 0 .../timed}/projects/tests/test_customer_assignee.py | 0 .../timed}/projects/tests/test_project.py | 0 .../timed}/projects/tests/test_project_assignee.py | 0 .../timed}/projects/tests/test_task.py | 0 .../timed}/projects/tests/test_task_assignee.py | 0 {timed => backend/timed}/projects/urls.py | 0 {timed => backend/timed}/projects/views.py | 0 {timed => backend/timed}/redmine/__init__.py | 0 {timed => backend/timed}/redmine/admin.py | 0 .../management/commands/import_project_data.py | 0 .../redmine/management/commands/redmine_report.py | 0 .../commands/update_project_expenditure.py | 0 .../timed}/redmine/migrations/0001_initial.py | 0 .../timed}/redmine/migrations/__init__.py | 0 {timed => backend/timed}/redmine/models.py | 0 .../redmine/templates/redmine/weekly_report.txt | 0 .../timed}/redmine/templatetags/__init__.py | 0 .../timed}/redmine/templatetags/float_hours.py | 0 {timed => backend/timed}/redmine/tests/__init__.py | 0 .../timed}/redmine/tests/test_redmine_report.py | 0 .../tests/test_update_project_expenditure.py | 0 {timed => backend/timed}/reports/__init__.py | 0 {timed => backend/timed}/reports/filters.py | 0 {timed => backend/timed}/reports/serializers.py | 0 .../timed}/reports/templates/workreport.ots | Bin {timed => backend/timed}/reports/tests/__init__.py | 0 .../timed}/reports/tests/test_customer_statistic.py | 0 .../timed}/reports/tests/test_month_statistic.py | 0 .../timed}/reports/tests/test_project_statistic.py | 0 .../timed}/reports/tests/test_task_statistic.py | 0 .../timed}/reports/tests/test_user_statistic.py | 0 .../timed}/reports/tests/test_work_report.py | 0 .../timed}/reports/tests/test_year_statistic.py | 0 {timed => backend/timed}/reports/urls.py | 0 {timed => backend/timed}/reports/views.py | 0 {timed => backend/timed}/serializers.py | 0 {timed => backend/timed}/settings.py | 0 {timed => backend/timed}/subscription/__init__.py | 0 {timed => backend/timed}/subscription/admin.py | 0 {timed => backend/timed}/subscription/factories.py | 0 {timed => backend/timed}/subscription/filters.py | 0 .../timed}/subscription/migrations/0001_initial.py | 0 .../migrations/0002_auto_20170808_1729.py | 0 .../migrations/0003_auto_20170907_1151.py | 0 .../migrations/0004_auto_20200407_2052.py | 0 .../migrations/0005_alter_package_price_currency.py | 0 .../migrations/0006_alter_package_price_currency.py | 0 .../timed}/subscription/migrations/__init__.py | 0 {timed => backend/timed}/subscription/models.py | 0 .../timed}/subscription/serializers.py | 0 .../templates/notify_accountants_order.html | 0 .../templates/notify_accountants_order.txt | 0 .../timed}/subscription/tests/__init__.py | 0 .../timed}/subscription/tests/test_order.py | 0 .../timed}/subscription/tests/test_package.py | 0 .../subscription/tests/test_subscription_project.py | 0 {timed => backend/timed}/subscription/urls.py | 0 {timed => backend/timed}/subscription/views.py | 0 {timed => backend/timed}/templates/login.html | 0 {timed => backend/timed}/tests/__init__.py | 0 .../timed}/tests/test_authentication.py | 0 {timed => backend/timed}/tests/test_settings.py | 0 {timed => backend/timed}/tracking/__init__.py | 0 {timed => backend/timed}/tracking/apps.py | 0 {timed => backend/timed}/tracking/factories.py | 0 {timed => backend/timed}/tracking/filters.py | 0 .../timed}/tracking/migrations/0001_initial.py | 0 .../tracking/migrations/0002_auto_20170912_1346.py | 0 .../tracking/migrations/0003_auto_20170912_1347.py | 0 .../tracking/migrations/0004_auto_20171005_1057.py | 0 .../migrations/0005_remove_absence_duration.py | 0 .../tracking/migrations/0006_add_activity_time.py | 0 .../migrations/0007_migrate_activity_blocks.py | 0 .../migrations/0008_delete_activity_blocks.py | 0 .../migrations/0009_remove_report_activity.py | 0 .../tracking/migrations/0010_auto_20180904_0818.py | 0 .../tracking/migrations/0011_auto_20181026_1528.py | 0 .../migrations/0012_migrate_report_review_false.py | 0 .../tracking/migrations/0013_report_billed.py | 0 .../0014_rename_type_absence_absence_type.py | 0 .../tracking/migrations/0015_report_rejected.py | 0 .../migrations/0016_report_remaining_effort.py | 0 .../0017_alter_report_remaining_effort.py | 0 .../timed}/tracking/migrations/__init__.py | 0 {timed => backend/timed}/tracking/models.py | 0 {timed => backend/timed}/tracking/serializers.py | 0 {timed => backend/timed}/tracking/signals.py | 0 {timed => backend/timed}/tracking/tasks.py | 0 .../templates/mail/notify_user_changed_reports.tmpl | 0 .../mail/notify_user_rejected_reports.tmpl | 0 .../timed}/tracking/templatetags/tracking_extras.py | 0 {timed => backend/timed}/tracking/tests/__init__.py | 0 .../timed}/tracking/tests/snapshots/__init__.py | 0 .../tracking/tests/snapshots/snap_test_report.py | 0 .../timed}/tracking/tests/test_absence.py | 0 .../timed}/tracking/tests/test_activity.py | 0 .../timed}/tracking/tests/test_attendance.py | 0 .../timed}/tracking/tests/test_report.py | 0 {timed => backend/timed}/tracking/urls.py | 0 {timed => backend/timed}/tracking/views.py | 0 {timed => backend/timed}/urls.py | 0 {timed => backend/timed}/wsgi.py | 0 220 files changed, 0 insertions(+), 0 deletions(-) rename .dockerignore => backend/.dockerignore (100%) rename .editorconfig => backend/.editorconfig (100%) rename .flake8 => backend/.flake8 (100%) rename {.github => backend/.github}/dependabot.yml (100%) rename {.github => backend/.github}/workflows/release.yaml (100%) rename {.github => backend/.github}/workflows/test.yml (100%) rename .gitignore => backend/.gitignore (100%) rename .pre-commit-config.yaml => backend/.pre-commit-config.yaml (100%) rename CHANGELOG.md => backend/CHANGELOG.md (100%) rename CODEOWNERS => backend/CODEOWNERS (100%) rename CONTRIBUTING.md => backend/CONTRIBUTING.md (100%) rename Dockerfile => backend/Dockerfile (100%) rename LICENSE => backend/LICENSE (100%) rename Makefile => backend/Makefile (100%) rename README.md => backend/README.md (100%) rename cmd.sh => backend/cmd.sh (100%) rename {dev-config => backend/dev-config}/keycloak-config.json (100%) rename {dev-config => backend/dev-config}/nginx.conf (100%) rename docker-compose.override.yml => backend/docker-compose.override.yml (100%) rename docker-compose.yml => backend/docker-compose.yml (100%) rename manage.py => backend/manage.py (100%) rename poetry.lock => backend/poetry.lock (100%) rename pyproject.toml => backend/pyproject.toml (100%) rename {timed => backend/timed}/.gitignore (100%) rename {timed => backend/timed}/__init__.py (100%) rename {timed => backend/timed}/admin.py (100%) rename {timed => backend/timed}/apps.py (100%) rename {timed => backend/timed}/authentication.py (100%) rename {timed => backend/timed}/conftest.py (100%) rename {timed => backend/timed}/employment/__init__.py (100%) rename {timed => backend/timed}/employment/admin.py (100%) rename {timed => backend/timed}/employment/apps.py (100%) rename {timed => backend/timed}/employment/factories.py (100%) rename {timed => backend/timed}/employment/filters.py (100%) rename {timed => backend/timed}/employment/migrations/0001_initial.py (100%) rename {timed => backend/timed}/employment/migrations/0002_auto_20170823_1051.py (100%) rename {timed => backend/timed}/employment/migrations/0003_user_tour_done.py (100%) rename {timed => backend/timed}/employment/migrations/0004_auto_20170904_1510.py (100%) rename {timed => backend/timed}/employment/migrations/0005_auto_20170906_1259.py (100%) rename {timed => backend/timed}/employment/migrations/0006_auto_20170906_1635.py (100%) rename {timed => backend/timed}/employment/migrations/0007_auto_20170911_0959.py (100%) rename {timed => backend/timed}/employment/migrations/0008_auto_20171013_1041.py (100%) rename {timed => backend/timed}/employment/migrations/0009_delete_userabsencetype.py (100%) rename {timed => backend/timed}/employment/migrations/0010_overtimecredit_comment.py (100%) rename {timed => backend/timed}/employment/migrations/0011_auto_20171101_1227.py (100%) rename {timed => backend/timed}/employment/migrations/0012_auto_20181026_1528.py (100%) rename {timed => backend/timed}/employment/migrations/0013_auto_20210302_1136.py (100%) rename {timed => backend/timed}/employment/migrations/0014_employment_is_external.py (100%) rename {timed => backend/timed}/employment/migrations/0015_user_is_accountant.py (100%) rename {timed => backend/timed}/employment/migrations/__init__.py (100%) rename {timed => backend/timed}/employment/models.py (100%) rename {timed => backend/timed}/employment/permissions.py (100%) rename {timed => backend/timed}/employment/relations.py (100%) rename {timed => backend/timed}/employment/serializers.py (100%) rename {timed => backend/timed}/employment/tests/__init__.py (100%) rename {timed => backend/timed}/employment/tests/test_absence_balance.py (100%) rename {timed => backend/timed}/employment/tests/test_absence_credit.py (100%) rename {timed => backend/timed}/employment/tests/test_absence_type.py (100%) rename {timed => backend/timed}/employment/tests/test_employment.py (100%) rename {timed => backend/timed}/employment/tests/test_location.py (100%) rename {timed => backend/timed}/employment/tests/test_overtime_credit.py (100%) rename {timed => backend/timed}/employment/tests/test_public_holiday.py (100%) rename {timed => backend/timed}/employment/tests/test_user.py (100%) rename {timed => backend/timed}/employment/tests/test_worktime_balance.py (100%) rename {timed => backend/timed}/employment/urls.py (100%) rename {timed => backend/timed}/employment/views.py (100%) rename {timed => backend/timed}/fixtures/test_data.json (100%) rename {timed => backend/timed}/forms.py (100%) rename {timed => backend/timed}/locale/en/LC_MESSAGES/django.po (100%) rename {timed => backend/timed}/mixins.py (100%) rename {timed => backend/timed}/models.py (100%) rename {timed => backend/timed}/notifications/__init__.py (100%) rename {timed => backend/timed}/notifications/factories.py (100%) rename {timed => backend/timed}/notifications/management/commands/budget_check.py (100%) rename {timed => backend/timed}/notifications/management/commands/notify_changed_employments.py (100%) rename {timed => backend/timed}/notifications/management/commands/notify_reviewers_unverified.py (100%) rename {timed => backend/timed}/notifications/management/commands/notify_supervisors_shorttime.py (100%) rename {timed => backend/timed}/notifications/migrations/0001_initial.py (100%) rename {timed => backend/timed}/notifications/migrations/0002_alter_notification_notification_type.py (100%) rename {timed => backend/timed}/notifications/migrations/__init__.py (100%) rename {timed => backend/timed}/notifications/models.py (100%) rename {timed => backend/timed}/notifications/notify_admin.py (100%) rename {timed => backend/timed}/notifications/templates/budget_reminder.txt (100%) rename {timed => backend/timed}/notifications/templates/mail/notify_changed_employments.txt (100%) rename {timed => backend/timed}/notifications/templates/mail/notify_reviewers_unverified.txt (100%) rename {timed => backend/timed}/notifications/templates/mail/notify_supervisor_shorttime.txt (100%) rename {timed => backend/timed}/notifications/tests/test_budget_check.py (100%) rename {timed => backend/timed}/notifications/tests/test_notify_changed_employments.py (100%) rename {timed => backend/timed}/notifications/tests/test_notify_reviewers_unverified.py (100%) rename {timed => backend/timed}/notifications/tests/test_notify_supervisors_shorttime.py (100%) rename {timed => backend/timed}/permissions.py (100%) rename {timed => backend/timed}/projects/__init__.py (100%) rename {timed => backend/timed}/projects/admin.py (100%) rename {timed => backend/timed}/projects/apps.py (100%) rename {timed => backend/timed}/projects/factories.py (100%) rename {timed => backend/timed}/projects/filters.py (100%) rename {timed => backend/timed}/projects/migrations/0001_initial.py (100%) rename {timed => backend/timed}/projects/migrations/0002_auto_20170823_1045.py (100%) rename {timed => backend/timed}/projects/migrations/0003_auto_20170831_1624.py (100%) rename {timed => backend/timed}/projects/migrations/0004_auto_20170906_1045.py (100%) rename {timed => backend/timed}/projects/migrations/0005_auto_20170907_0938.py (100%) rename {timed => backend/timed}/projects/migrations/0006_auto_20171010_1423.py (100%) rename {timed => backend/timed}/projects/migrations/0007_project_subscription_project.py (100%) rename {timed => backend/timed}/projects/migrations/0008_auto_20190220_1133.py (100%) rename {timed => backend/timed}/projects/migrations/0009_auto_20201201_1412.py (100%) rename {timed => backend/timed}/projects/migrations/0010_project_billed.py (100%) rename {timed => backend/timed}/projects/migrations/0011_auto_20210419_1459.py (100%) rename {timed => backend/timed}/projects/migrations/0012_migrate_reviewers_to_assignees.py (100%) rename {timed => backend/timed}/projects/migrations/0013_remove_project_reviewers.py (100%) rename {timed => backend/timed}/projects/migrations/0014_add_is_customer_role_to_assignees.py (100%) rename {timed => backend/timed}/projects/migrations/0015_remaining_effort_task_project.py (100%) rename {timed => backend/timed}/projects/migrations/__init__.py (100%) rename {timed => backend/timed}/projects/models.py (100%) rename {timed => backend/timed}/projects/serializers.py (100%) rename {timed => backend/timed}/projects/tests/__init__.py (100%) rename {timed => backend/timed}/projects/tests/test_billing_type.py (100%) rename {timed => backend/timed}/projects/tests/test_cost_center.py (100%) rename {timed => backend/timed}/projects/tests/test_customer.py (100%) rename {timed => backend/timed}/projects/tests/test_customer_assignee.py (100%) rename {timed => backend/timed}/projects/tests/test_project.py (100%) rename {timed => backend/timed}/projects/tests/test_project_assignee.py (100%) rename {timed => backend/timed}/projects/tests/test_task.py (100%) rename {timed => backend/timed}/projects/tests/test_task_assignee.py (100%) rename {timed => backend/timed}/projects/urls.py (100%) rename {timed => backend/timed}/projects/views.py (100%) rename {timed => backend/timed}/redmine/__init__.py (100%) rename {timed => backend/timed}/redmine/admin.py (100%) rename {timed => backend/timed}/redmine/management/commands/import_project_data.py (100%) rename {timed => backend/timed}/redmine/management/commands/redmine_report.py (100%) rename {timed => backend/timed}/redmine/management/commands/update_project_expenditure.py (100%) rename {timed => backend/timed}/redmine/migrations/0001_initial.py (100%) rename {timed => backend/timed}/redmine/migrations/__init__.py (100%) rename {timed => backend/timed}/redmine/models.py (100%) rename {timed => backend/timed}/redmine/templates/redmine/weekly_report.txt (100%) rename {timed => backend/timed}/redmine/templatetags/__init__.py (100%) rename {timed => backend/timed}/redmine/templatetags/float_hours.py (100%) rename {timed => backend/timed}/redmine/tests/__init__.py (100%) rename {timed => backend/timed}/redmine/tests/test_redmine_report.py (100%) rename {timed => backend/timed}/redmine/tests/test_update_project_expenditure.py (100%) rename {timed => backend/timed}/reports/__init__.py (100%) rename {timed => backend/timed}/reports/filters.py (100%) rename {timed => backend/timed}/reports/serializers.py (100%) rename {timed => backend/timed}/reports/templates/workreport.ots (100%) rename {timed => backend/timed}/reports/tests/__init__.py (100%) rename {timed => backend/timed}/reports/tests/test_customer_statistic.py (100%) rename {timed => backend/timed}/reports/tests/test_month_statistic.py (100%) rename {timed => backend/timed}/reports/tests/test_project_statistic.py (100%) rename {timed => backend/timed}/reports/tests/test_task_statistic.py (100%) rename {timed => backend/timed}/reports/tests/test_user_statistic.py (100%) rename {timed => backend/timed}/reports/tests/test_work_report.py (100%) rename {timed => backend/timed}/reports/tests/test_year_statistic.py (100%) rename {timed => backend/timed}/reports/urls.py (100%) rename {timed => backend/timed}/reports/views.py (100%) rename {timed => backend/timed}/serializers.py (100%) rename {timed => backend/timed}/settings.py (100%) rename {timed => backend/timed}/subscription/__init__.py (100%) rename {timed => backend/timed}/subscription/admin.py (100%) rename {timed => backend/timed}/subscription/factories.py (100%) rename {timed => backend/timed}/subscription/filters.py (100%) rename {timed => backend/timed}/subscription/migrations/0001_initial.py (100%) rename {timed => backend/timed}/subscription/migrations/0002_auto_20170808_1729.py (100%) rename {timed => backend/timed}/subscription/migrations/0003_auto_20170907_1151.py (100%) rename {timed => backend/timed}/subscription/migrations/0004_auto_20200407_2052.py (100%) rename {timed => backend/timed}/subscription/migrations/0005_alter_package_price_currency.py (100%) rename {timed => backend/timed}/subscription/migrations/0006_alter_package_price_currency.py (100%) rename {timed => backend/timed}/subscription/migrations/__init__.py (100%) rename {timed => backend/timed}/subscription/models.py (100%) rename {timed => backend/timed}/subscription/serializers.py (100%) rename {timed => backend/timed}/subscription/templates/notify_accountants_order.html (100%) rename {timed => backend/timed}/subscription/templates/notify_accountants_order.txt (100%) rename {timed => backend/timed}/subscription/tests/__init__.py (100%) rename {timed => backend/timed}/subscription/tests/test_order.py (100%) rename {timed => backend/timed}/subscription/tests/test_package.py (100%) rename {timed => backend/timed}/subscription/tests/test_subscription_project.py (100%) rename {timed => backend/timed}/subscription/urls.py (100%) rename {timed => backend/timed}/subscription/views.py (100%) rename {timed => backend/timed}/templates/login.html (100%) rename {timed => backend/timed}/tests/__init__.py (100%) rename {timed => backend/timed}/tests/test_authentication.py (100%) rename {timed => backend/timed}/tests/test_settings.py (100%) rename {timed => backend/timed}/tracking/__init__.py (100%) rename {timed => backend/timed}/tracking/apps.py (100%) rename {timed => backend/timed}/tracking/factories.py (100%) rename {timed => backend/timed}/tracking/filters.py (100%) rename {timed => backend/timed}/tracking/migrations/0001_initial.py (100%) rename {timed => backend/timed}/tracking/migrations/0002_auto_20170912_1346.py (100%) rename {timed => backend/timed}/tracking/migrations/0003_auto_20170912_1347.py (100%) rename {timed => backend/timed}/tracking/migrations/0004_auto_20171005_1057.py (100%) rename {timed => backend/timed}/tracking/migrations/0005_remove_absence_duration.py (100%) rename {timed => backend/timed}/tracking/migrations/0006_add_activity_time.py (100%) rename {timed => backend/timed}/tracking/migrations/0007_migrate_activity_blocks.py (100%) rename {timed => backend/timed}/tracking/migrations/0008_delete_activity_blocks.py (100%) rename {timed => backend/timed}/tracking/migrations/0009_remove_report_activity.py (100%) rename {timed => backend/timed}/tracking/migrations/0010_auto_20180904_0818.py (100%) rename {timed => backend/timed}/tracking/migrations/0011_auto_20181026_1528.py (100%) rename {timed => backend/timed}/tracking/migrations/0012_migrate_report_review_false.py (100%) rename {timed => backend/timed}/tracking/migrations/0013_report_billed.py (100%) rename {timed => backend/timed}/tracking/migrations/0014_rename_type_absence_absence_type.py (100%) rename {timed => backend/timed}/tracking/migrations/0015_report_rejected.py (100%) rename {timed => backend/timed}/tracking/migrations/0016_report_remaining_effort.py (100%) rename {timed => backend/timed}/tracking/migrations/0017_alter_report_remaining_effort.py (100%) rename {timed => backend/timed}/tracking/migrations/__init__.py (100%) rename {timed => backend/timed}/tracking/models.py (100%) rename {timed => backend/timed}/tracking/serializers.py (100%) rename {timed => backend/timed}/tracking/signals.py (100%) rename {timed => backend/timed}/tracking/tasks.py (100%) rename {timed => backend/timed}/tracking/templates/mail/notify_user_changed_reports.tmpl (100%) rename {timed => backend/timed}/tracking/templates/mail/notify_user_rejected_reports.tmpl (100%) rename {timed => backend/timed}/tracking/templatetags/tracking_extras.py (100%) rename {timed => backend/timed}/tracking/tests/__init__.py (100%) rename {timed => backend/timed}/tracking/tests/snapshots/__init__.py (100%) rename {timed => backend/timed}/tracking/tests/snapshots/snap_test_report.py (100%) rename {timed => backend/timed}/tracking/tests/test_absence.py (100%) rename {timed => backend/timed}/tracking/tests/test_activity.py (100%) rename {timed => backend/timed}/tracking/tests/test_attendance.py (100%) rename {timed => backend/timed}/tracking/tests/test_report.py (100%) rename {timed => backend/timed}/tracking/urls.py (100%) rename {timed => backend/timed}/tracking/views.py (100%) rename {timed => backend/timed}/urls.py (100%) rename {timed => backend/timed}/wsgi.py (100%) diff --git a/.dockerignore b/backend/.dockerignore similarity index 100% rename from .dockerignore rename to backend/.dockerignore diff --git a/.editorconfig b/backend/.editorconfig similarity index 100% rename from .editorconfig rename to backend/.editorconfig diff --git a/.flake8 b/backend/.flake8 similarity index 100% rename from .flake8 rename to backend/.flake8 diff --git a/.github/dependabot.yml b/backend/.github/dependabot.yml similarity index 100% rename from .github/dependabot.yml rename to backend/.github/dependabot.yml diff --git a/.github/workflows/release.yaml b/backend/.github/workflows/release.yaml similarity index 100% rename from .github/workflows/release.yaml rename to backend/.github/workflows/release.yaml diff --git a/.github/workflows/test.yml b/backend/.github/workflows/test.yml similarity index 100% rename from .github/workflows/test.yml rename to backend/.github/workflows/test.yml diff --git a/.gitignore b/backend/.gitignore similarity index 100% rename from .gitignore rename to backend/.gitignore diff --git a/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml similarity index 100% rename from .pre-commit-config.yaml rename to backend/.pre-commit-config.yaml diff --git a/CHANGELOG.md b/backend/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to backend/CHANGELOG.md diff --git a/CODEOWNERS b/backend/CODEOWNERS similarity index 100% rename from CODEOWNERS rename to backend/CODEOWNERS diff --git a/CONTRIBUTING.md b/backend/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to backend/CONTRIBUTING.md diff --git a/Dockerfile b/backend/Dockerfile similarity index 100% rename from Dockerfile rename to backend/Dockerfile diff --git a/LICENSE b/backend/LICENSE similarity index 100% rename from LICENSE rename to backend/LICENSE diff --git a/Makefile b/backend/Makefile similarity index 100% rename from Makefile rename to backend/Makefile diff --git a/README.md b/backend/README.md similarity index 100% rename from README.md rename to backend/README.md diff --git a/cmd.sh b/backend/cmd.sh similarity index 100% rename from cmd.sh rename to backend/cmd.sh diff --git a/dev-config/keycloak-config.json b/backend/dev-config/keycloak-config.json similarity index 100% rename from dev-config/keycloak-config.json rename to backend/dev-config/keycloak-config.json diff --git a/dev-config/nginx.conf b/backend/dev-config/nginx.conf similarity index 100% rename from dev-config/nginx.conf rename to backend/dev-config/nginx.conf diff --git a/docker-compose.override.yml b/backend/docker-compose.override.yml similarity index 100% rename from docker-compose.override.yml rename to backend/docker-compose.override.yml diff --git a/docker-compose.yml b/backend/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to backend/docker-compose.yml diff --git a/manage.py b/backend/manage.py similarity index 100% rename from manage.py rename to backend/manage.py diff --git a/poetry.lock b/backend/poetry.lock similarity index 100% rename from poetry.lock rename to backend/poetry.lock diff --git a/pyproject.toml b/backend/pyproject.toml similarity index 100% rename from pyproject.toml rename to backend/pyproject.toml diff --git a/timed/.gitignore b/backend/timed/.gitignore similarity index 100% rename from timed/.gitignore rename to backend/timed/.gitignore diff --git a/timed/__init__.py b/backend/timed/__init__.py similarity index 100% rename from timed/__init__.py rename to backend/timed/__init__.py diff --git a/timed/admin.py b/backend/timed/admin.py similarity index 100% rename from timed/admin.py rename to backend/timed/admin.py diff --git a/timed/apps.py b/backend/timed/apps.py similarity index 100% rename from timed/apps.py rename to backend/timed/apps.py diff --git a/timed/authentication.py b/backend/timed/authentication.py similarity index 100% rename from timed/authentication.py rename to backend/timed/authentication.py diff --git a/timed/conftest.py b/backend/timed/conftest.py similarity index 100% rename from timed/conftest.py rename to backend/timed/conftest.py diff --git a/timed/employment/__init__.py b/backend/timed/employment/__init__.py similarity index 100% rename from timed/employment/__init__.py rename to backend/timed/employment/__init__.py diff --git a/timed/employment/admin.py b/backend/timed/employment/admin.py similarity index 100% rename from timed/employment/admin.py rename to backend/timed/employment/admin.py diff --git a/timed/employment/apps.py b/backend/timed/employment/apps.py similarity index 100% rename from timed/employment/apps.py rename to backend/timed/employment/apps.py diff --git a/timed/employment/factories.py b/backend/timed/employment/factories.py similarity index 100% rename from timed/employment/factories.py rename to backend/timed/employment/factories.py diff --git a/timed/employment/filters.py b/backend/timed/employment/filters.py similarity index 100% rename from timed/employment/filters.py rename to backend/timed/employment/filters.py diff --git a/timed/employment/migrations/0001_initial.py b/backend/timed/employment/migrations/0001_initial.py similarity index 100% rename from timed/employment/migrations/0001_initial.py rename to backend/timed/employment/migrations/0001_initial.py diff --git a/timed/employment/migrations/0002_auto_20170823_1051.py b/backend/timed/employment/migrations/0002_auto_20170823_1051.py similarity index 100% rename from timed/employment/migrations/0002_auto_20170823_1051.py rename to backend/timed/employment/migrations/0002_auto_20170823_1051.py diff --git a/timed/employment/migrations/0003_user_tour_done.py b/backend/timed/employment/migrations/0003_user_tour_done.py similarity index 100% rename from timed/employment/migrations/0003_user_tour_done.py rename to backend/timed/employment/migrations/0003_user_tour_done.py diff --git a/timed/employment/migrations/0004_auto_20170904_1510.py b/backend/timed/employment/migrations/0004_auto_20170904_1510.py similarity index 100% rename from timed/employment/migrations/0004_auto_20170904_1510.py rename to backend/timed/employment/migrations/0004_auto_20170904_1510.py diff --git a/timed/employment/migrations/0005_auto_20170906_1259.py b/backend/timed/employment/migrations/0005_auto_20170906_1259.py similarity index 100% rename from timed/employment/migrations/0005_auto_20170906_1259.py rename to backend/timed/employment/migrations/0005_auto_20170906_1259.py diff --git a/timed/employment/migrations/0006_auto_20170906_1635.py b/backend/timed/employment/migrations/0006_auto_20170906_1635.py similarity index 100% rename from timed/employment/migrations/0006_auto_20170906_1635.py rename to backend/timed/employment/migrations/0006_auto_20170906_1635.py diff --git a/timed/employment/migrations/0007_auto_20170911_0959.py b/backend/timed/employment/migrations/0007_auto_20170911_0959.py similarity index 100% rename from timed/employment/migrations/0007_auto_20170911_0959.py rename to backend/timed/employment/migrations/0007_auto_20170911_0959.py diff --git a/timed/employment/migrations/0008_auto_20171013_1041.py b/backend/timed/employment/migrations/0008_auto_20171013_1041.py similarity index 100% rename from timed/employment/migrations/0008_auto_20171013_1041.py rename to backend/timed/employment/migrations/0008_auto_20171013_1041.py diff --git a/timed/employment/migrations/0009_delete_userabsencetype.py b/backend/timed/employment/migrations/0009_delete_userabsencetype.py similarity index 100% rename from timed/employment/migrations/0009_delete_userabsencetype.py rename to backend/timed/employment/migrations/0009_delete_userabsencetype.py diff --git a/timed/employment/migrations/0010_overtimecredit_comment.py b/backend/timed/employment/migrations/0010_overtimecredit_comment.py similarity index 100% rename from timed/employment/migrations/0010_overtimecredit_comment.py rename to backend/timed/employment/migrations/0010_overtimecredit_comment.py diff --git a/timed/employment/migrations/0011_auto_20171101_1227.py b/backend/timed/employment/migrations/0011_auto_20171101_1227.py similarity index 100% rename from timed/employment/migrations/0011_auto_20171101_1227.py rename to backend/timed/employment/migrations/0011_auto_20171101_1227.py diff --git a/timed/employment/migrations/0012_auto_20181026_1528.py b/backend/timed/employment/migrations/0012_auto_20181026_1528.py similarity index 100% rename from timed/employment/migrations/0012_auto_20181026_1528.py rename to backend/timed/employment/migrations/0012_auto_20181026_1528.py diff --git a/timed/employment/migrations/0013_auto_20210302_1136.py b/backend/timed/employment/migrations/0013_auto_20210302_1136.py similarity index 100% rename from timed/employment/migrations/0013_auto_20210302_1136.py rename to backend/timed/employment/migrations/0013_auto_20210302_1136.py diff --git a/timed/employment/migrations/0014_employment_is_external.py b/backend/timed/employment/migrations/0014_employment_is_external.py similarity index 100% rename from timed/employment/migrations/0014_employment_is_external.py rename to backend/timed/employment/migrations/0014_employment_is_external.py diff --git a/timed/employment/migrations/0015_user_is_accountant.py b/backend/timed/employment/migrations/0015_user_is_accountant.py similarity index 100% rename from timed/employment/migrations/0015_user_is_accountant.py rename to backend/timed/employment/migrations/0015_user_is_accountant.py diff --git a/timed/employment/migrations/__init__.py b/backend/timed/employment/migrations/__init__.py similarity index 100% rename from timed/employment/migrations/__init__.py rename to backend/timed/employment/migrations/__init__.py diff --git a/timed/employment/models.py b/backend/timed/employment/models.py similarity index 100% rename from timed/employment/models.py rename to backend/timed/employment/models.py diff --git a/timed/employment/permissions.py b/backend/timed/employment/permissions.py similarity index 100% rename from timed/employment/permissions.py rename to backend/timed/employment/permissions.py diff --git a/timed/employment/relations.py b/backend/timed/employment/relations.py similarity index 100% rename from timed/employment/relations.py rename to backend/timed/employment/relations.py diff --git a/timed/employment/serializers.py b/backend/timed/employment/serializers.py similarity index 100% rename from timed/employment/serializers.py rename to backend/timed/employment/serializers.py diff --git a/timed/employment/tests/__init__.py b/backend/timed/employment/tests/__init__.py similarity index 100% rename from timed/employment/tests/__init__.py rename to backend/timed/employment/tests/__init__.py diff --git a/timed/employment/tests/test_absence_balance.py b/backend/timed/employment/tests/test_absence_balance.py similarity index 100% rename from timed/employment/tests/test_absence_balance.py rename to backend/timed/employment/tests/test_absence_balance.py diff --git a/timed/employment/tests/test_absence_credit.py b/backend/timed/employment/tests/test_absence_credit.py similarity index 100% rename from timed/employment/tests/test_absence_credit.py rename to backend/timed/employment/tests/test_absence_credit.py diff --git a/timed/employment/tests/test_absence_type.py b/backend/timed/employment/tests/test_absence_type.py similarity index 100% rename from timed/employment/tests/test_absence_type.py rename to backend/timed/employment/tests/test_absence_type.py diff --git a/timed/employment/tests/test_employment.py b/backend/timed/employment/tests/test_employment.py similarity index 100% rename from timed/employment/tests/test_employment.py rename to backend/timed/employment/tests/test_employment.py diff --git a/timed/employment/tests/test_location.py b/backend/timed/employment/tests/test_location.py similarity index 100% rename from timed/employment/tests/test_location.py rename to backend/timed/employment/tests/test_location.py diff --git a/timed/employment/tests/test_overtime_credit.py b/backend/timed/employment/tests/test_overtime_credit.py similarity index 100% rename from timed/employment/tests/test_overtime_credit.py rename to backend/timed/employment/tests/test_overtime_credit.py diff --git a/timed/employment/tests/test_public_holiday.py b/backend/timed/employment/tests/test_public_holiday.py similarity index 100% rename from timed/employment/tests/test_public_holiday.py rename to backend/timed/employment/tests/test_public_holiday.py diff --git a/timed/employment/tests/test_user.py b/backend/timed/employment/tests/test_user.py similarity index 100% rename from timed/employment/tests/test_user.py rename to backend/timed/employment/tests/test_user.py diff --git a/timed/employment/tests/test_worktime_balance.py b/backend/timed/employment/tests/test_worktime_balance.py similarity index 100% rename from timed/employment/tests/test_worktime_balance.py rename to backend/timed/employment/tests/test_worktime_balance.py diff --git a/timed/employment/urls.py b/backend/timed/employment/urls.py similarity index 100% rename from timed/employment/urls.py rename to backend/timed/employment/urls.py diff --git a/timed/employment/views.py b/backend/timed/employment/views.py similarity index 100% rename from timed/employment/views.py rename to backend/timed/employment/views.py diff --git a/timed/fixtures/test_data.json b/backend/timed/fixtures/test_data.json similarity index 100% rename from timed/fixtures/test_data.json rename to backend/timed/fixtures/test_data.json diff --git a/timed/forms.py b/backend/timed/forms.py similarity index 100% rename from timed/forms.py rename to backend/timed/forms.py diff --git a/timed/locale/en/LC_MESSAGES/django.po b/backend/timed/locale/en/LC_MESSAGES/django.po similarity index 100% rename from timed/locale/en/LC_MESSAGES/django.po rename to backend/timed/locale/en/LC_MESSAGES/django.po diff --git a/timed/mixins.py b/backend/timed/mixins.py similarity index 100% rename from timed/mixins.py rename to backend/timed/mixins.py diff --git a/timed/models.py b/backend/timed/models.py similarity index 100% rename from timed/models.py rename to backend/timed/models.py diff --git a/timed/notifications/__init__.py b/backend/timed/notifications/__init__.py similarity index 100% rename from timed/notifications/__init__.py rename to backend/timed/notifications/__init__.py diff --git a/timed/notifications/factories.py b/backend/timed/notifications/factories.py similarity index 100% rename from timed/notifications/factories.py rename to backend/timed/notifications/factories.py diff --git a/timed/notifications/management/commands/budget_check.py b/backend/timed/notifications/management/commands/budget_check.py similarity index 100% rename from timed/notifications/management/commands/budget_check.py rename to backend/timed/notifications/management/commands/budget_check.py diff --git a/timed/notifications/management/commands/notify_changed_employments.py b/backend/timed/notifications/management/commands/notify_changed_employments.py similarity index 100% rename from timed/notifications/management/commands/notify_changed_employments.py rename to backend/timed/notifications/management/commands/notify_changed_employments.py diff --git a/timed/notifications/management/commands/notify_reviewers_unverified.py b/backend/timed/notifications/management/commands/notify_reviewers_unverified.py similarity index 100% rename from timed/notifications/management/commands/notify_reviewers_unverified.py rename to backend/timed/notifications/management/commands/notify_reviewers_unverified.py diff --git a/timed/notifications/management/commands/notify_supervisors_shorttime.py b/backend/timed/notifications/management/commands/notify_supervisors_shorttime.py similarity index 100% rename from timed/notifications/management/commands/notify_supervisors_shorttime.py rename to backend/timed/notifications/management/commands/notify_supervisors_shorttime.py diff --git a/timed/notifications/migrations/0001_initial.py b/backend/timed/notifications/migrations/0001_initial.py similarity index 100% rename from timed/notifications/migrations/0001_initial.py rename to backend/timed/notifications/migrations/0001_initial.py diff --git a/timed/notifications/migrations/0002_alter_notification_notification_type.py b/backend/timed/notifications/migrations/0002_alter_notification_notification_type.py similarity index 100% rename from timed/notifications/migrations/0002_alter_notification_notification_type.py rename to backend/timed/notifications/migrations/0002_alter_notification_notification_type.py diff --git a/timed/notifications/migrations/__init__.py b/backend/timed/notifications/migrations/__init__.py similarity index 100% rename from timed/notifications/migrations/__init__.py rename to backend/timed/notifications/migrations/__init__.py diff --git a/timed/notifications/models.py b/backend/timed/notifications/models.py similarity index 100% rename from timed/notifications/models.py rename to backend/timed/notifications/models.py diff --git a/timed/notifications/notify_admin.py b/backend/timed/notifications/notify_admin.py similarity index 100% rename from timed/notifications/notify_admin.py rename to backend/timed/notifications/notify_admin.py diff --git a/timed/notifications/templates/budget_reminder.txt b/backend/timed/notifications/templates/budget_reminder.txt similarity index 100% rename from timed/notifications/templates/budget_reminder.txt rename to backend/timed/notifications/templates/budget_reminder.txt diff --git a/timed/notifications/templates/mail/notify_changed_employments.txt b/backend/timed/notifications/templates/mail/notify_changed_employments.txt similarity index 100% rename from timed/notifications/templates/mail/notify_changed_employments.txt rename to backend/timed/notifications/templates/mail/notify_changed_employments.txt diff --git a/timed/notifications/templates/mail/notify_reviewers_unverified.txt b/backend/timed/notifications/templates/mail/notify_reviewers_unverified.txt similarity index 100% rename from timed/notifications/templates/mail/notify_reviewers_unverified.txt rename to backend/timed/notifications/templates/mail/notify_reviewers_unverified.txt diff --git a/timed/notifications/templates/mail/notify_supervisor_shorttime.txt b/backend/timed/notifications/templates/mail/notify_supervisor_shorttime.txt similarity index 100% rename from timed/notifications/templates/mail/notify_supervisor_shorttime.txt rename to backend/timed/notifications/templates/mail/notify_supervisor_shorttime.txt diff --git a/timed/notifications/tests/test_budget_check.py b/backend/timed/notifications/tests/test_budget_check.py similarity index 100% rename from timed/notifications/tests/test_budget_check.py rename to backend/timed/notifications/tests/test_budget_check.py diff --git a/timed/notifications/tests/test_notify_changed_employments.py b/backend/timed/notifications/tests/test_notify_changed_employments.py similarity index 100% rename from timed/notifications/tests/test_notify_changed_employments.py rename to backend/timed/notifications/tests/test_notify_changed_employments.py diff --git a/timed/notifications/tests/test_notify_reviewers_unverified.py b/backend/timed/notifications/tests/test_notify_reviewers_unverified.py similarity index 100% rename from timed/notifications/tests/test_notify_reviewers_unverified.py rename to backend/timed/notifications/tests/test_notify_reviewers_unverified.py diff --git a/timed/notifications/tests/test_notify_supervisors_shorttime.py b/backend/timed/notifications/tests/test_notify_supervisors_shorttime.py similarity index 100% rename from timed/notifications/tests/test_notify_supervisors_shorttime.py rename to backend/timed/notifications/tests/test_notify_supervisors_shorttime.py diff --git a/timed/permissions.py b/backend/timed/permissions.py similarity index 100% rename from timed/permissions.py rename to backend/timed/permissions.py diff --git a/timed/projects/__init__.py b/backend/timed/projects/__init__.py similarity index 100% rename from timed/projects/__init__.py rename to backend/timed/projects/__init__.py diff --git a/timed/projects/admin.py b/backend/timed/projects/admin.py similarity index 100% rename from timed/projects/admin.py rename to backend/timed/projects/admin.py diff --git a/timed/projects/apps.py b/backend/timed/projects/apps.py similarity index 100% rename from timed/projects/apps.py rename to backend/timed/projects/apps.py diff --git a/timed/projects/factories.py b/backend/timed/projects/factories.py similarity index 100% rename from timed/projects/factories.py rename to backend/timed/projects/factories.py diff --git a/timed/projects/filters.py b/backend/timed/projects/filters.py similarity index 100% rename from timed/projects/filters.py rename to backend/timed/projects/filters.py diff --git a/timed/projects/migrations/0001_initial.py b/backend/timed/projects/migrations/0001_initial.py similarity index 100% rename from timed/projects/migrations/0001_initial.py rename to backend/timed/projects/migrations/0001_initial.py diff --git a/timed/projects/migrations/0002_auto_20170823_1045.py b/backend/timed/projects/migrations/0002_auto_20170823_1045.py similarity index 100% rename from timed/projects/migrations/0002_auto_20170823_1045.py rename to backend/timed/projects/migrations/0002_auto_20170823_1045.py diff --git a/timed/projects/migrations/0003_auto_20170831_1624.py b/backend/timed/projects/migrations/0003_auto_20170831_1624.py similarity index 100% rename from timed/projects/migrations/0003_auto_20170831_1624.py rename to backend/timed/projects/migrations/0003_auto_20170831_1624.py diff --git a/timed/projects/migrations/0004_auto_20170906_1045.py b/backend/timed/projects/migrations/0004_auto_20170906_1045.py similarity index 100% rename from timed/projects/migrations/0004_auto_20170906_1045.py rename to backend/timed/projects/migrations/0004_auto_20170906_1045.py diff --git a/timed/projects/migrations/0005_auto_20170907_0938.py b/backend/timed/projects/migrations/0005_auto_20170907_0938.py similarity index 100% rename from timed/projects/migrations/0005_auto_20170907_0938.py rename to backend/timed/projects/migrations/0005_auto_20170907_0938.py diff --git a/timed/projects/migrations/0006_auto_20171010_1423.py b/backend/timed/projects/migrations/0006_auto_20171010_1423.py similarity index 100% rename from timed/projects/migrations/0006_auto_20171010_1423.py rename to backend/timed/projects/migrations/0006_auto_20171010_1423.py diff --git a/timed/projects/migrations/0007_project_subscription_project.py b/backend/timed/projects/migrations/0007_project_subscription_project.py similarity index 100% rename from timed/projects/migrations/0007_project_subscription_project.py rename to backend/timed/projects/migrations/0007_project_subscription_project.py diff --git a/timed/projects/migrations/0008_auto_20190220_1133.py b/backend/timed/projects/migrations/0008_auto_20190220_1133.py similarity index 100% rename from timed/projects/migrations/0008_auto_20190220_1133.py rename to backend/timed/projects/migrations/0008_auto_20190220_1133.py diff --git a/timed/projects/migrations/0009_auto_20201201_1412.py b/backend/timed/projects/migrations/0009_auto_20201201_1412.py similarity index 100% rename from timed/projects/migrations/0009_auto_20201201_1412.py rename to backend/timed/projects/migrations/0009_auto_20201201_1412.py diff --git a/timed/projects/migrations/0010_project_billed.py b/backend/timed/projects/migrations/0010_project_billed.py similarity index 100% rename from timed/projects/migrations/0010_project_billed.py rename to backend/timed/projects/migrations/0010_project_billed.py diff --git a/timed/projects/migrations/0011_auto_20210419_1459.py b/backend/timed/projects/migrations/0011_auto_20210419_1459.py similarity index 100% rename from timed/projects/migrations/0011_auto_20210419_1459.py rename to backend/timed/projects/migrations/0011_auto_20210419_1459.py diff --git a/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py b/backend/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py similarity index 100% rename from timed/projects/migrations/0012_migrate_reviewers_to_assignees.py rename to backend/timed/projects/migrations/0012_migrate_reviewers_to_assignees.py diff --git a/timed/projects/migrations/0013_remove_project_reviewers.py b/backend/timed/projects/migrations/0013_remove_project_reviewers.py similarity index 100% rename from timed/projects/migrations/0013_remove_project_reviewers.py rename to backend/timed/projects/migrations/0013_remove_project_reviewers.py diff --git a/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py b/backend/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py similarity index 100% rename from timed/projects/migrations/0014_add_is_customer_role_to_assignees.py rename to backend/timed/projects/migrations/0014_add_is_customer_role_to_assignees.py diff --git a/timed/projects/migrations/0015_remaining_effort_task_project.py b/backend/timed/projects/migrations/0015_remaining_effort_task_project.py similarity index 100% rename from timed/projects/migrations/0015_remaining_effort_task_project.py rename to backend/timed/projects/migrations/0015_remaining_effort_task_project.py diff --git a/timed/projects/migrations/__init__.py b/backend/timed/projects/migrations/__init__.py similarity index 100% rename from timed/projects/migrations/__init__.py rename to backend/timed/projects/migrations/__init__.py diff --git a/timed/projects/models.py b/backend/timed/projects/models.py similarity index 100% rename from timed/projects/models.py rename to backend/timed/projects/models.py diff --git a/timed/projects/serializers.py b/backend/timed/projects/serializers.py similarity index 100% rename from timed/projects/serializers.py rename to backend/timed/projects/serializers.py diff --git a/timed/projects/tests/__init__.py b/backend/timed/projects/tests/__init__.py similarity index 100% rename from timed/projects/tests/__init__.py rename to backend/timed/projects/tests/__init__.py diff --git a/timed/projects/tests/test_billing_type.py b/backend/timed/projects/tests/test_billing_type.py similarity index 100% rename from timed/projects/tests/test_billing_type.py rename to backend/timed/projects/tests/test_billing_type.py diff --git a/timed/projects/tests/test_cost_center.py b/backend/timed/projects/tests/test_cost_center.py similarity index 100% rename from timed/projects/tests/test_cost_center.py rename to backend/timed/projects/tests/test_cost_center.py diff --git a/timed/projects/tests/test_customer.py b/backend/timed/projects/tests/test_customer.py similarity index 100% rename from timed/projects/tests/test_customer.py rename to backend/timed/projects/tests/test_customer.py diff --git a/timed/projects/tests/test_customer_assignee.py b/backend/timed/projects/tests/test_customer_assignee.py similarity index 100% rename from timed/projects/tests/test_customer_assignee.py rename to backend/timed/projects/tests/test_customer_assignee.py diff --git a/timed/projects/tests/test_project.py b/backend/timed/projects/tests/test_project.py similarity index 100% rename from timed/projects/tests/test_project.py rename to backend/timed/projects/tests/test_project.py diff --git a/timed/projects/tests/test_project_assignee.py b/backend/timed/projects/tests/test_project_assignee.py similarity index 100% rename from timed/projects/tests/test_project_assignee.py rename to backend/timed/projects/tests/test_project_assignee.py diff --git a/timed/projects/tests/test_task.py b/backend/timed/projects/tests/test_task.py similarity index 100% rename from timed/projects/tests/test_task.py rename to backend/timed/projects/tests/test_task.py diff --git a/timed/projects/tests/test_task_assignee.py b/backend/timed/projects/tests/test_task_assignee.py similarity index 100% rename from timed/projects/tests/test_task_assignee.py rename to backend/timed/projects/tests/test_task_assignee.py diff --git a/timed/projects/urls.py b/backend/timed/projects/urls.py similarity index 100% rename from timed/projects/urls.py rename to backend/timed/projects/urls.py diff --git a/timed/projects/views.py b/backend/timed/projects/views.py similarity index 100% rename from timed/projects/views.py rename to backend/timed/projects/views.py diff --git a/timed/redmine/__init__.py b/backend/timed/redmine/__init__.py similarity index 100% rename from timed/redmine/__init__.py rename to backend/timed/redmine/__init__.py diff --git a/timed/redmine/admin.py b/backend/timed/redmine/admin.py similarity index 100% rename from timed/redmine/admin.py rename to backend/timed/redmine/admin.py diff --git a/timed/redmine/management/commands/import_project_data.py b/backend/timed/redmine/management/commands/import_project_data.py similarity index 100% rename from timed/redmine/management/commands/import_project_data.py rename to backend/timed/redmine/management/commands/import_project_data.py diff --git a/timed/redmine/management/commands/redmine_report.py b/backend/timed/redmine/management/commands/redmine_report.py similarity index 100% rename from timed/redmine/management/commands/redmine_report.py rename to backend/timed/redmine/management/commands/redmine_report.py diff --git a/timed/redmine/management/commands/update_project_expenditure.py b/backend/timed/redmine/management/commands/update_project_expenditure.py similarity index 100% rename from timed/redmine/management/commands/update_project_expenditure.py rename to backend/timed/redmine/management/commands/update_project_expenditure.py diff --git a/timed/redmine/migrations/0001_initial.py b/backend/timed/redmine/migrations/0001_initial.py similarity index 100% rename from timed/redmine/migrations/0001_initial.py rename to backend/timed/redmine/migrations/0001_initial.py diff --git a/timed/redmine/migrations/__init__.py b/backend/timed/redmine/migrations/__init__.py similarity index 100% rename from timed/redmine/migrations/__init__.py rename to backend/timed/redmine/migrations/__init__.py diff --git a/timed/redmine/models.py b/backend/timed/redmine/models.py similarity index 100% rename from timed/redmine/models.py rename to backend/timed/redmine/models.py diff --git a/timed/redmine/templates/redmine/weekly_report.txt b/backend/timed/redmine/templates/redmine/weekly_report.txt similarity index 100% rename from timed/redmine/templates/redmine/weekly_report.txt rename to backend/timed/redmine/templates/redmine/weekly_report.txt diff --git a/timed/redmine/templatetags/__init__.py b/backend/timed/redmine/templatetags/__init__.py similarity index 100% rename from timed/redmine/templatetags/__init__.py rename to backend/timed/redmine/templatetags/__init__.py diff --git a/timed/redmine/templatetags/float_hours.py b/backend/timed/redmine/templatetags/float_hours.py similarity index 100% rename from timed/redmine/templatetags/float_hours.py rename to backend/timed/redmine/templatetags/float_hours.py diff --git a/timed/redmine/tests/__init__.py b/backend/timed/redmine/tests/__init__.py similarity index 100% rename from timed/redmine/tests/__init__.py rename to backend/timed/redmine/tests/__init__.py diff --git a/timed/redmine/tests/test_redmine_report.py b/backend/timed/redmine/tests/test_redmine_report.py similarity index 100% rename from timed/redmine/tests/test_redmine_report.py rename to backend/timed/redmine/tests/test_redmine_report.py diff --git a/timed/redmine/tests/test_update_project_expenditure.py b/backend/timed/redmine/tests/test_update_project_expenditure.py similarity index 100% rename from timed/redmine/tests/test_update_project_expenditure.py rename to backend/timed/redmine/tests/test_update_project_expenditure.py diff --git a/timed/reports/__init__.py b/backend/timed/reports/__init__.py similarity index 100% rename from timed/reports/__init__.py rename to backend/timed/reports/__init__.py diff --git a/timed/reports/filters.py b/backend/timed/reports/filters.py similarity index 100% rename from timed/reports/filters.py rename to backend/timed/reports/filters.py diff --git a/timed/reports/serializers.py b/backend/timed/reports/serializers.py similarity index 100% rename from timed/reports/serializers.py rename to backend/timed/reports/serializers.py diff --git a/timed/reports/templates/workreport.ots b/backend/timed/reports/templates/workreport.ots similarity index 100% rename from timed/reports/templates/workreport.ots rename to backend/timed/reports/templates/workreport.ots diff --git a/timed/reports/tests/__init__.py b/backend/timed/reports/tests/__init__.py similarity index 100% rename from timed/reports/tests/__init__.py rename to backend/timed/reports/tests/__init__.py diff --git a/timed/reports/tests/test_customer_statistic.py b/backend/timed/reports/tests/test_customer_statistic.py similarity index 100% rename from timed/reports/tests/test_customer_statistic.py rename to backend/timed/reports/tests/test_customer_statistic.py diff --git a/timed/reports/tests/test_month_statistic.py b/backend/timed/reports/tests/test_month_statistic.py similarity index 100% rename from timed/reports/tests/test_month_statistic.py rename to backend/timed/reports/tests/test_month_statistic.py diff --git a/timed/reports/tests/test_project_statistic.py b/backend/timed/reports/tests/test_project_statistic.py similarity index 100% rename from timed/reports/tests/test_project_statistic.py rename to backend/timed/reports/tests/test_project_statistic.py diff --git a/timed/reports/tests/test_task_statistic.py b/backend/timed/reports/tests/test_task_statistic.py similarity index 100% rename from timed/reports/tests/test_task_statistic.py rename to backend/timed/reports/tests/test_task_statistic.py diff --git a/timed/reports/tests/test_user_statistic.py b/backend/timed/reports/tests/test_user_statistic.py similarity index 100% rename from timed/reports/tests/test_user_statistic.py rename to backend/timed/reports/tests/test_user_statistic.py diff --git a/timed/reports/tests/test_work_report.py b/backend/timed/reports/tests/test_work_report.py similarity index 100% rename from timed/reports/tests/test_work_report.py rename to backend/timed/reports/tests/test_work_report.py diff --git a/timed/reports/tests/test_year_statistic.py b/backend/timed/reports/tests/test_year_statistic.py similarity index 100% rename from timed/reports/tests/test_year_statistic.py rename to backend/timed/reports/tests/test_year_statistic.py diff --git a/timed/reports/urls.py b/backend/timed/reports/urls.py similarity index 100% rename from timed/reports/urls.py rename to backend/timed/reports/urls.py diff --git a/timed/reports/views.py b/backend/timed/reports/views.py similarity index 100% rename from timed/reports/views.py rename to backend/timed/reports/views.py diff --git a/timed/serializers.py b/backend/timed/serializers.py similarity index 100% rename from timed/serializers.py rename to backend/timed/serializers.py diff --git a/timed/settings.py b/backend/timed/settings.py similarity index 100% rename from timed/settings.py rename to backend/timed/settings.py diff --git a/timed/subscription/__init__.py b/backend/timed/subscription/__init__.py similarity index 100% rename from timed/subscription/__init__.py rename to backend/timed/subscription/__init__.py diff --git a/timed/subscription/admin.py b/backend/timed/subscription/admin.py similarity index 100% rename from timed/subscription/admin.py rename to backend/timed/subscription/admin.py diff --git a/timed/subscription/factories.py b/backend/timed/subscription/factories.py similarity index 100% rename from timed/subscription/factories.py rename to backend/timed/subscription/factories.py diff --git a/timed/subscription/filters.py b/backend/timed/subscription/filters.py similarity index 100% rename from timed/subscription/filters.py rename to backend/timed/subscription/filters.py diff --git a/timed/subscription/migrations/0001_initial.py b/backend/timed/subscription/migrations/0001_initial.py similarity index 100% rename from timed/subscription/migrations/0001_initial.py rename to backend/timed/subscription/migrations/0001_initial.py diff --git a/timed/subscription/migrations/0002_auto_20170808_1729.py b/backend/timed/subscription/migrations/0002_auto_20170808_1729.py similarity index 100% rename from timed/subscription/migrations/0002_auto_20170808_1729.py rename to backend/timed/subscription/migrations/0002_auto_20170808_1729.py diff --git a/timed/subscription/migrations/0003_auto_20170907_1151.py b/backend/timed/subscription/migrations/0003_auto_20170907_1151.py similarity index 100% rename from timed/subscription/migrations/0003_auto_20170907_1151.py rename to backend/timed/subscription/migrations/0003_auto_20170907_1151.py diff --git a/timed/subscription/migrations/0004_auto_20200407_2052.py b/backend/timed/subscription/migrations/0004_auto_20200407_2052.py similarity index 100% rename from timed/subscription/migrations/0004_auto_20200407_2052.py rename to backend/timed/subscription/migrations/0004_auto_20200407_2052.py diff --git a/timed/subscription/migrations/0005_alter_package_price_currency.py b/backend/timed/subscription/migrations/0005_alter_package_price_currency.py similarity index 100% rename from timed/subscription/migrations/0005_alter_package_price_currency.py rename to backend/timed/subscription/migrations/0005_alter_package_price_currency.py diff --git a/timed/subscription/migrations/0006_alter_package_price_currency.py b/backend/timed/subscription/migrations/0006_alter_package_price_currency.py similarity index 100% rename from timed/subscription/migrations/0006_alter_package_price_currency.py rename to backend/timed/subscription/migrations/0006_alter_package_price_currency.py diff --git a/timed/subscription/migrations/__init__.py b/backend/timed/subscription/migrations/__init__.py similarity index 100% rename from timed/subscription/migrations/__init__.py rename to backend/timed/subscription/migrations/__init__.py diff --git a/timed/subscription/models.py b/backend/timed/subscription/models.py similarity index 100% rename from timed/subscription/models.py rename to backend/timed/subscription/models.py diff --git a/timed/subscription/serializers.py b/backend/timed/subscription/serializers.py similarity index 100% rename from timed/subscription/serializers.py rename to backend/timed/subscription/serializers.py diff --git a/timed/subscription/templates/notify_accountants_order.html b/backend/timed/subscription/templates/notify_accountants_order.html similarity index 100% rename from timed/subscription/templates/notify_accountants_order.html rename to backend/timed/subscription/templates/notify_accountants_order.html diff --git a/timed/subscription/templates/notify_accountants_order.txt b/backend/timed/subscription/templates/notify_accountants_order.txt similarity index 100% rename from timed/subscription/templates/notify_accountants_order.txt rename to backend/timed/subscription/templates/notify_accountants_order.txt diff --git a/timed/subscription/tests/__init__.py b/backend/timed/subscription/tests/__init__.py similarity index 100% rename from timed/subscription/tests/__init__.py rename to backend/timed/subscription/tests/__init__.py diff --git a/timed/subscription/tests/test_order.py b/backend/timed/subscription/tests/test_order.py similarity index 100% rename from timed/subscription/tests/test_order.py rename to backend/timed/subscription/tests/test_order.py diff --git a/timed/subscription/tests/test_package.py b/backend/timed/subscription/tests/test_package.py similarity index 100% rename from timed/subscription/tests/test_package.py rename to backend/timed/subscription/tests/test_package.py diff --git a/timed/subscription/tests/test_subscription_project.py b/backend/timed/subscription/tests/test_subscription_project.py similarity index 100% rename from timed/subscription/tests/test_subscription_project.py rename to backend/timed/subscription/tests/test_subscription_project.py diff --git a/timed/subscription/urls.py b/backend/timed/subscription/urls.py similarity index 100% rename from timed/subscription/urls.py rename to backend/timed/subscription/urls.py diff --git a/timed/subscription/views.py b/backend/timed/subscription/views.py similarity index 100% rename from timed/subscription/views.py rename to backend/timed/subscription/views.py diff --git a/timed/templates/login.html b/backend/timed/templates/login.html similarity index 100% rename from timed/templates/login.html rename to backend/timed/templates/login.html diff --git a/timed/tests/__init__.py b/backend/timed/tests/__init__.py similarity index 100% rename from timed/tests/__init__.py rename to backend/timed/tests/__init__.py diff --git a/timed/tests/test_authentication.py b/backend/timed/tests/test_authentication.py similarity index 100% rename from timed/tests/test_authentication.py rename to backend/timed/tests/test_authentication.py diff --git a/timed/tests/test_settings.py b/backend/timed/tests/test_settings.py similarity index 100% rename from timed/tests/test_settings.py rename to backend/timed/tests/test_settings.py diff --git a/timed/tracking/__init__.py b/backend/timed/tracking/__init__.py similarity index 100% rename from timed/tracking/__init__.py rename to backend/timed/tracking/__init__.py diff --git a/timed/tracking/apps.py b/backend/timed/tracking/apps.py similarity index 100% rename from timed/tracking/apps.py rename to backend/timed/tracking/apps.py diff --git a/timed/tracking/factories.py b/backend/timed/tracking/factories.py similarity index 100% rename from timed/tracking/factories.py rename to backend/timed/tracking/factories.py diff --git a/timed/tracking/filters.py b/backend/timed/tracking/filters.py similarity index 100% rename from timed/tracking/filters.py rename to backend/timed/tracking/filters.py diff --git a/timed/tracking/migrations/0001_initial.py b/backend/timed/tracking/migrations/0001_initial.py similarity index 100% rename from timed/tracking/migrations/0001_initial.py rename to backend/timed/tracking/migrations/0001_initial.py diff --git a/timed/tracking/migrations/0002_auto_20170912_1346.py b/backend/timed/tracking/migrations/0002_auto_20170912_1346.py similarity index 100% rename from timed/tracking/migrations/0002_auto_20170912_1346.py rename to backend/timed/tracking/migrations/0002_auto_20170912_1346.py diff --git a/timed/tracking/migrations/0003_auto_20170912_1347.py b/backend/timed/tracking/migrations/0003_auto_20170912_1347.py similarity index 100% rename from timed/tracking/migrations/0003_auto_20170912_1347.py rename to backend/timed/tracking/migrations/0003_auto_20170912_1347.py diff --git a/timed/tracking/migrations/0004_auto_20171005_1057.py b/backend/timed/tracking/migrations/0004_auto_20171005_1057.py similarity index 100% rename from timed/tracking/migrations/0004_auto_20171005_1057.py rename to backend/timed/tracking/migrations/0004_auto_20171005_1057.py diff --git a/timed/tracking/migrations/0005_remove_absence_duration.py b/backend/timed/tracking/migrations/0005_remove_absence_duration.py similarity index 100% rename from timed/tracking/migrations/0005_remove_absence_duration.py rename to backend/timed/tracking/migrations/0005_remove_absence_duration.py diff --git a/timed/tracking/migrations/0006_add_activity_time.py b/backend/timed/tracking/migrations/0006_add_activity_time.py similarity index 100% rename from timed/tracking/migrations/0006_add_activity_time.py rename to backend/timed/tracking/migrations/0006_add_activity_time.py diff --git a/timed/tracking/migrations/0007_migrate_activity_blocks.py b/backend/timed/tracking/migrations/0007_migrate_activity_blocks.py similarity index 100% rename from timed/tracking/migrations/0007_migrate_activity_blocks.py rename to backend/timed/tracking/migrations/0007_migrate_activity_blocks.py diff --git a/timed/tracking/migrations/0008_delete_activity_blocks.py b/backend/timed/tracking/migrations/0008_delete_activity_blocks.py similarity index 100% rename from timed/tracking/migrations/0008_delete_activity_blocks.py rename to backend/timed/tracking/migrations/0008_delete_activity_blocks.py diff --git a/timed/tracking/migrations/0009_remove_report_activity.py b/backend/timed/tracking/migrations/0009_remove_report_activity.py similarity index 100% rename from timed/tracking/migrations/0009_remove_report_activity.py rename to backend/timed/tracking/migrations/0009_remove_report_activity.py diff --git a/timed/tracking/migrations/0010_auto_20180904_0818.py b/backend/timed/tracking/migrations/0010_auto_20180904_0818.py similarity index 100% rename from timed/tracking/migrations/0010_auto_20180904_0818.py rename to backend/timed/tracking/migrations/0010_auto_20180904_0818.py diff --git a/timed/tracking/migrations/0011_auto_20181026_1528.py b/backend/timed/tracking/migrations/0011_auto_20181026_1528.py similarity index 100% rename from timed/tracking/migrations/0011_auto_20181026_1528.py rename to backend/timed/tracking/migrations/0011_auto_20181026_1528.py diff --git a/timed/tracking/migrations/0012_migrate_report_review_false.py b/backend/timed/tracking/migrations/0012_migrate_report_review_false.py similarity index 100% rename from timed/tracking/migrations/0012_migrate_report_review_false.py rename to backend/timed/tracking/migrations/0012_migrate_report_review_false.py diff --git a/timed/tracking/migrations/0013_report_billed.py b/backend/timed/tracking/migrations/0013_report_billed.py similarity index 100% rename from timed/tracking/migrations/0013_report_billed.py rename to backend/timed/tracking/migrations/0013_report_billed.py diff --git a/timed/tracking/migrations/0014_rename_type_absence_absence_type.py b/backend/timed/tracking/migrations/0014_rename_type_absence_absence_type.py similarity index 100% rename from timed/tracking/migrations/0014_rename_type_absence_absence_type.py rename to backend/timed/tracking/migrations/0014_rename_type_absence_absence_type.py diff --git a/timed/tracking/migrations/0015_report_rejected.py b/backend/timed/tracking/migrations/0015_report_rejected.py similarity index 100% rename from timed/tracking/migrations/0015_report_rejected.py rename to backend/timed/tracking/migrations/0015_report_rejected.py diff --git a/timed/tracking/migrations/0016_report_remaining_effort.py b/backend/timed/tracking/migrations/0016_report_remaining_effort.py similarity index 100% rename from timed/tracking/migrations/0016_report_remaining_effort.py rename to backend/timed/tracking/migrations/0016_report_remaining_effort.py diff --git a/timed/tracking/migrations/0017_alter_report_remaining_effort.py b/backend/timed/tracking/migrations/0017_alter_report_remaining_effort.py similarity index 100% rename from timed/tracking/migrations/0017_alter_report_remaining_effort.py rename to backend/timed/tracking/migrations/0017_alter_report_remaining_effort.py diff --git a/timed/tracking/migrations/__init__.py b/backend/timed/tracking/migrations/__init__.py similarity index 100% rename from timed/tracking/migrations/__init__.py rename to backend/timed/tracking/migrations/__init__.py diff --git a/timed/tracking/models.py b/backend/timed/tracking/models.py similarity index 100% rename from timed/tracking/models.py rename to backend/timed/tracking/models.py diff --git a/timed/tracking/serializers.py b/backend/timed/tracking/serializers.py similarity index 100% rename from timed/tracking/serializers.py rename to backend/timed/tracking/serializers.py diff --git a/timed/tracking/signals.py b/backend/timed/tracking/signals.py similarity index 100% rename from timed/tracking/signals.py rename to backend/timed/tracking/signals.py diff --git a/timed/tracking/tasks.py b/backend/timed/tracking/tasks.py similarity index 100% rename from timed/tracking/tasks.py rename to backend/timed/tracking/tasks.py diff --git a/timed/tracking/templates/mail/notify_user_changed_reports.tmpl b/backend/timed/tracking/templates/mail/notify_user_changed_reports.tmpl similarity index 100% rename from timed/tracking/templates/mail/notify_user_changed_reports.tmpl rename to backend/timed/tracking/templates/mail/notify_user_changed_reports.tmpl diff --git a/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl b/backend/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl similarity index 100% rename from timed/tracking/templates/mail/notify_user_rejected_reports.tmpl rename to backend/timed/tracking/templates/mail/notify_user_rejected_reports.tmpl diff --git a/timed/tracking/templatetags/tracking_extras.py b/backend/timed/tracking/templatetags/tracking_extras.py similarity index 100% rename from timed/tracking/templatetags/tracking_extras.py rename to backend/timed/tracking/templatetags/tracking_extras.py diff --git a/timed/tracking/tests/__init__.py b/backend/timed/tracking/tests/__init__.py similarity index 100% rename from timed/tracking/tests/__init__.py rename to backend/timed/tracking/tests/__init__.py diff --git a/timed/tracking/tests/snapshots/__init__.py b/backend/timed/tracking/tests/snapshots/__init__.py similarity index 100% rename from timed/tracking/tests/snapshots/__init__.py rename to backend/timed/tracking/tests/snapshots/__init__.py diff --git a/timed/tracking/tests/snapshots/snap_test_report.py b/backend/timed/tracking/tests/snapshots/snap_test_report.py similarity index 100% rename from timed/tracking/tests/snapshots/snap_test_report.py rename to backend/timed/tracking/tests/snapshots/snap_test_report.py diff --git a/timed/tracking/tests/test_absence.py b/backend/timed/tracking/tests/test_absence.py similarity index 100% rename from timed/tracking/tests/test_absence.py rename to backend/timed/tracking/tests/test_absence.py diff --git a/timed/tracking/tests/test_activity.py b/backend/timed/tracking/tests/test_activity.py similarity index 100% rename from timed/tracking/tests/test_activity.py rename to backend/timed/tracking/tests/test_activity.py diff --git a/timed/tracking/tests/test_attendance.py b/backend/timed/tracking/tests/test_attendance.py similarity index 100% rename from timed/tracking/tests/test_attendance.py rename to backend/timed/tracking/tests/test_attendance.py diff --git a/timed/tracking/tests/test_report.py b/backend/timed/tracking/tests/test_report.py similarity index 100% rename from timed/tracking/tests/test_report.py rename to backend/timed/tracking/tests/test_report.py diff --git a/timed/tracking/urls.py b/backend/timed/tracking/urls.py similarity index 100% rename from timed/tracking/urls.py rename to backend/timed/tracking/urls.py diff --git a/timed/tracking/views.py b/backend/timed/tracking/views.py similarity index 100% rename from timed/tracking/views.py rename to backend/timed/tracking/views.py diff --git a/timed/urls.py b/backend/timed/urls.py similarity index 100% rename from timed/urls.py rename to backend/timed/urls.py diff --git a/timed/wsgi.py b/backend/timed/wsgi.py similarity index 100% rename from timed/wsgi.py rename to backend/timed/wsgi.py From c795ef8cd1460cd6c6ee6c759f1cfd0561a9edb2 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Wed, 6 Dec 2023 09:54:29 +0100 Subject: [PATCH 980/980] WIP: restructuring files for mono repo - refactor dependabot - refactor test workflow - refactor release-npm.yml - refactor release-iamge.yml - move .gitignore - refactor .editorconfig - move LICENSE - refactor CODEOWNERS - refactor docker-compose - move Makefile - WIP: husky - refactor CONTRIBUTING - refactor README --- frontend/.editorconfig => .editorconfig | 3 + .github/dependabot.yml | 38 + .../workflows/release-image.yml | 11 +- .../workflows/release-npm.yml | 5 + .../.github => .github}/workflows/test.yml | 34 +- backend/.gitignore => .gitignore | 14 +- CODEOWNERS | 3 + backend/CONTRIBUTING.md => CONTRIBUTING.md | 18 +- frontend/LICENSE => LICENSE | 0 backend/Makefile => Makefile | 0 frontend/README.md => README-frontend.md | 0 backend/README.md => README.md | 36 +- backend/.editorconfig | 17 - backend/.github/dependabot.yml | 22 - backend/.github/workflows/release.yaml | 51 -- backend/.github/workflows/test.yml | 30 - backend/CODEOWNERS | 3 - backend/LICENSE | 661 ------------------ .../keycloak-config.json | 0 {backend/dev-config => dev-config}/nginx.conf | 0 ...verride.yml => docker-compose.override.yml | 14 +- .../docker-compose.yml => docker-compose.yml | 13 +- frontend/.github/dependabot.yml | 18 - frontend/.gitignore | 6 - frontend/.husky/commit-msg | 12 +- frontend/.husky/pre-commit | 12 +- frontend/docker-compose.yml | 43 -- frontend/package.json | 2 +- 28 files changed, 170 insertions(+), 896 deletions(-) rename frontend/.editorconfig => .editorconfig (93%) create mode 100644 .github/dependabot.yml rename {frontend/.github => .github}/workflows/release-image.yml (85%) rename {frontend/.github => .github}/workflows/release-npm.yml (83%) rename {frontend/.github => .github}/workflows/test.yml (56%) rename backend/.gitignore => .gitignore (85%) create mode 100644 CODEOWNERS rename backend/CONTRIBUTING.md => CONTRIBUTING.md (76%) rename frontend/LICENSE => LICENSE (100%) rename backend/Makefile => Makefile (100%) rename frontend/README.md => README-frontend.md (100%) rename backend/README.md => README.md (93%) delete mode 100644 backend/.editorconfig delete mode 100644 backend/.github/dependabot.yml delete mode 100644 backend/.github/workflows/release.yaml delete mode 100644 backend/.github/workflows/test.yml delete mode 100644 backend/CODEOWNERS delete mode 100644 backend/LICENSE rename {backend/dev-config => dev-config}/keycloak-config.json (100%) rename {backend/dev-config => dev-config}/nginx.conf (100%) rename backend/docker-compose.override.yml => docker-compose.override.yml (84%) rename backend/docker-compose.yml => docker-compose.yml (64%) delete mode 100644 frontend/.github/dependabot.yml delete mode 100644 frontend/docker-compose.yml diff --git a/frontend/.editorconfig b/.editorconfig similarity index 93% rename from frontend/.editorconfig rename to .editorconfig index c35a00240..a1f2b6ae8 100644 --- a/frontend/.editorconfig +++ b/.editorconfig @@ -17,3 +17,6 @@ insert_final_newline = false [*.{diff,md}] trim_trailing_whitespace = false + +[*.py] +indent_size = 4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..81341246c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: "weekly" + day: "friday" + time: "12:00" + timezone: "Europe/Zurich" + - package-ecosystem: npm + directory: "/frontend" + schedule: + interval: "weekly" + day: "friday" + time: "12:00" + timezone: "Europe/Zurich" + open-pull-requests-limit: 10 + versioning-strategy: increase + - package-ecosystem: pip + directory: "/backend" + schedule: + interval: weekly + day: friday + time: "12:00" + timezone: "Europe/Zurich" + commit-message: + prefix: chore + include: scope + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + day: friday + time: "12:00" + timezone: "Europe/Zurich" + commit-message: + prefix: chore + include: scope diff --git a/frontend/.github/workflows/release-image.yml b/.github/workflows/release-image.yml similarity index 85% rename from frontend/.github/workflows/release-image.yml rename to .github/workflows/release-image.yml index 1af7d355e..aacc33302 100644 --- a/frontend/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -1,4 +1,4 @@ -name: Release ghcr image +name: Release ghcr images on: release: @@ -7,6 +7,9 @@ on: jobs: container: runs-on: ubuntu-latest + strategy: + matrix: + target: [frontend, backend] steps: - name: Checkout uses: actions/checkout@v4 @@ -15,11 +18,11 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/adfinis/timed-frontend + images: ghcr.io/adfinis/timed-${{ matrix.target }} flavor: | latest=auto labels: | - org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.title=${{ github.event.repository.name }}-${{ matrix.target }} org.opencontainers.image.description=${{ github.event.repository.description }} org.opencontainers.image.url=${{ github.event.repository.html_url }} org.opencontainers.image.source=${{ github.event.repository.clone_url }} @@ -38,7 +41,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: ./Dockerfile + file: ./${{ matrix.target }}/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: | diff --git a/frontend/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml similarity index 83% rename from frontend/.github/workflows/release-npm.yml rename to .github/workflows/release-npm.yml index 2a9bdf9b0..e0ba93238 100644 --- a/frontend/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -2,6 +2,9 @@ name: Release npm package on: workflow_dispatch +env: + frontend-dir: ./frontend + jobs: release: name: Release @@ -24,9 +27,11 @@ jobs: - name: Install dependencies run: pnpm install + working-directory: ${{ env.frontend-dir }} - name: Release on NPM run: pnpm semantic-release + working-directory: ${{ env.frontend-dir }} env: GH_TOKEN: ${{ secrets.GH_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/frontend/.github/workflows/test.yml b/.github/workflows/test.yml similarity index 56% rename from frontend/.github/workflows/test.yml rename to .github/workflows/test.yml index e715ef5b2..aaeae566f 100644 --- a/frontend/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,9 +13,11 @@ on: env: NODE_VERSION: 18 + frontend-dir: ./frontend + backend-dir: ./backend jobs: - lint: + lint-frontend: name: Lint runs-on: [ubuntu-latest] timeout-minutes: 5 @@ -39,13 +41,15 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile + working-directory: ${{ env.frontend-dir }} - name: Lint ${{ matrix.target }} run: pnpm lint:${{ matrix.target }} + working-directory: ${{ env.frontend-dir }} - test: + test-frontend: name: Tests - needs: [lint] + needs: [lint-frontend] runs-on: [ubuntu-latest] timeout-minutes: 10 @@ -64,9 +68,11 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile + working-directory: ${{ env.frontend-dir }} - name: Run tests run: pnpm test + working-directory: ${{ env.frontend-dir }} env: COVERAGE: true @@ -74,3 +80,25 @@ jobs: uses: codecov/codecov-action@v3 with: file: ./coverage/lcov.info + + test-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/cache@v3 + with: + path: .venv + key: poetry-${{ hashFiles('poetry.lock')}} + restore-keys: | + peotry- + - name: Build the project + run: | + echo "ENV=dev" > .env + docker-compose up -d --build backend + - name: Lint the code + run: | + docker-compose exec -T backend black --check . + docker-compose exec -T backend flake8 + docker-compose exec -T backend python manage.py makemigrations --check --dry-run --no-input + - name: Run pytest + run: docker-compose exec -T backend pytest --no-cov-on-fail --cov --create-db -vv diff --git a/backend/.gitignore b/.gitignore similarity index 85% rename from backend/.gitignore rename to .gitignore index c84cfc961..7b301953c 100644 --- a/backend/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + + +# VSCode +.vscode/ + +# PyCharm +.idea/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -73,5 +82,6 @@ target/ # pytest .pytest_cache -# PyCharm -.idea +# dependencies +/node_modules +/bower_components diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..4fcfacde5 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,3 @@ +# Code owners for the Timed backend. +* @adfinis/dev-backend +* @adfinis/dev-frontend diff --git a/backend/CONTRIBUTING.md b/CONTRIBUTING.md similarity index 76% rename from backend/CONTRIBUTING.md rename to CONTRIBUTING.md index 02510312d..0208ebe21 100644 --- a/backend/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,18 @@ # Contributing -Contributions to Timed backend are very welcome! Best have a look at the open [issues](https://github.com/adfinis/timed-backend) -and open a [GitHub pull request](https://github.com/adfinis/timed-backend/compare). See instructions below how to setup development +Contributions to Timed are very welcome! Best have a look at the open [issues](https://github.com/adfinis/timed) +and open a [GitHub pull request](https://github.com/adfinis/timed/compare). See instructions below how to setup development environment. Before writing any code, best discuss your proposed change in a GitHub issue to see if the proposed change makes sense for the project. ## Setup development environment ### Clone -To work on Timed backend you first need to clone +To work on Timed you first need to clone ```bash -git clone https://github.com/adfinis/timed-backend.git -cd timed-backend +git clone https://github.com/adfinis/timed.git +cd timed ``` ### Open Shell @@ -32,13 +32,13 @@ etc. ```bash # linting -flake8 +poetry run flake8 # format code -black . +poetry run black . # running tests -pytest +poetry run pytest # create migrations -python manage.py makemigrations +poetry run python manage.py makemigrations ``` Writing of code can still happen outside the docker container of course. diff --git a/frontend/LICENSE b/LICENSE similarity index 100% rename from frontend/LICENSE rename to LICENSE diff --git a/backend/Makefile b/Makefile similarity index 100% rename from backend/Makefile rename to Makefile diff --git a/frontend/README.md b/README-frontend.md similarity index 100% rename from frontend/README.md rename to README-frontend.md diff --git a/backend/README.md b/README.md similarity index 93% rename from backend/README.md rename to README.md index 07375f6c1..f967d5810 100644 --- a/backend/README.md +++ b/README.md @@ -52,12 +52,17 @@ DJANGO_OIDC_USERNAME_CLAIM=preferred_username ``` The test data includes 3 users admin, fritzm and alexs with you can log into [http://timed.local](http://timed.local) +You can initialize the test data using the following command: + +```bash +make loaddata +``` The username and password are identical. To access the Django admin interface you will have to change the admin password in Django directly: -```console +```bash $ make bash root@0a036a10f3c4:/app# poetry run python manage.py changepassword admin Changing password for user 'admin' @@ -68,6 +73,29 @@ Password changed successfully for user 'admin' Then you'll be able to login in the Django admin interface [http://timed.local/admin/](http://timed.local/admin/). +## Work locally with Ember + +```bash +cd frontend +pnpm i +``` + +## Running / Development + +```bash +ember server +``` +- Visit your app at [http://localhost:4200](http://localhost:4200). + +If you have a running [backend](https://github.com/adfinis/timed-backend) you need to run + +```bash +ember server --proxy=http://localhost:8000 +``` +or +```bash +pnpm start +``` ### Adding a user @@ -78,6 +106,12 @@ You should see that new user in the `Employment -> Users`. Click on the user and scroll down to the `Employments` section to set a `Location`. Save the user and you should now see the _Timed_ interface correctly under that account. + +### Sending emails +In development mode, the apllication is configured to send all email to a Mailhog instance running in the same docker-compose setup. No emails will be sent out from the development environment, unless you specify something else. + +You can access the Mailhog interface at [http://timed.local/mailhog/](http://timed.local/mailhog/). All emails sent from the application will be visible there. + ## Configuration Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) diff --git a/backend/.editorconfig b/backend/.editorconfig deleted file mode 100644 index 229c447c1..000000000 --- a/backend/.editorconfig +++ /dev/null @@ -1,17 +0,0 @@ -root = true - -[*] -end_of_line = lf -charset = utf-8 - -[*.py] -indent_style = space -indent_size = 4 - -[*.json] -indent_style = space -indent_size = 2 - -[*.yml] -indent_style = space -indent_size = 2 diff --git a/backend/.github/dependabot.yml b/backend/.github/dependabot.yml deleted file mode 100644 index 1881b89d4..000000000 --- a/backend/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 -updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: weekly - day: friday - time: "12:00" - timezone: "Europe/Zurich" - commit-message: - prefix: chore - include: scope -- package-ecosystem: docker - directory: "/" - schedule: - interval: weekly - day: friday - time: "12:00" - timezone: "Europe/Zurich" - commit-message: - prefix: chore - include: scope diff --git a/backend/.github/workflows/release.yaml b/backend/.github/workflows/release.yaml deleted file mode 100644 index 2c702dd85..000000000 --- a/backend/.github/workflows/release.yaml +++ /dev/null @@ -1,51 +0,0 @@ -name: Release Container Image - -on: - pull_request: - push: - branches: - - main - - master - tags: - - 'v*.*.*' - -jobs: - container: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v4 - with: - images: ghcr.io/adfinis/timed-backend - flavor: | - latest=auto - labels: | - org.opencontainers.image.title=${{ github.event.repository.name }} - org.opencontainers.image.description=${{ github.event.repository.description }} - org.opencontainers.image.url=${{ github.event.repository.html_url }} - org.opencontainers.image.source=${{ github.event.repository.clone_url }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} - - - name: Login to GHCR - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - if: ${{ github.event_name != 'pull_request' }} - - - name: Build and push - id: docker_build_ghcr - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: | - ${{ steps.meta.outputs.labels }} diff --git a/backend/.github/workflows/test.yml b/backend/.github/workflows/test.yml deleted file mode 100644 index c8e507e6d..000000000 --- a/backend/.github/workflows/test.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Test - -on: - push: - pull_request: - schedule: - - cron: '0 0 * * 0' - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/cache@v3 - with: - path: .venv - key: poetry-${{ hashFiles('poetry.lock')}} - restore-keys: | - peotry- - - name: Build the project - run: | - echo "ENV=dev" > .env - docker-compose up -d --build backend - - name: Lint the code - run: | - docker-compose exec -T backend black --check . - docker-compose exec -T backend flake8 - docker-compose exec -T backend python manage.py makemigrations --check --dry-run --no-input - - name: Run pytest - run: docker-compose exec -T backend pytest --no-cov-on-fail --cov --create-db -vv diff --git a/backend/CODEOWNERS b/backend/CODEOWNERS deleted file mode 100644 index 66419d391..000000000 --- a/backend/CODEOWNERS +++ /dev/null @@ -1,3 +0,0 @@ -# Code owners for the Timed backend. We include our backend dev team here. -# Since this is a split project (backend/frontend) it's rather simple -* @adfinis/dev-backend diff --git a/backend/LICENSE b/backend/LICENSE deleted file mode 100644 index 197242a90..000000000 --- a/backend/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 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 Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - timed-backend.src - Copyright (C) 2016 ad-sy - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/backend/dev-config/keycloak-config.json b/dev-config/keycloak-config.json similarity index 100% rename from backend/dev-config/keycloak-config.json rename to dev-config/keycloak-config.json diff --git a/backend/dev-config/nginx.conf b/dev-config/nginx.conf similarity index 100% rename from backend/dev-config/nginx.conf rename to dev-config/nginx.conf diff --git a/backend/docker-compose.override.yml b/docker-compose.override.yml similarity index 84% rename from backend/docker-compose.override.yml rename to docker-compose.override.yml index 6c364ba10..49a5716d0 100644 --- a/backend/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -3,7 +3,7 @@ version: "3.7" services: backend: build: - context: . + context: ./backend/ args: INSTALL_DEV_DEPENDENCIES: "true" depends_on: @@ -13,23 +13,13 @@ services: - EMAIL_URL=smtp://mailhog:1025 - DJANGO_OIDC_USERNAME_CLAIM=preferred_username volumes: - - ./:/app + - ./backend/:/app command: /bin/sh cmd.sh --autoreload --static ports: - "81:81" networks: - timed.local - frontend: - image: ghcr.io/adfinis/timed-frontend:latest - ports: - - 4200:80 - environment: - - TIMED_SSO_CLIENT_HOST=http://timed.local/auth/realms/timed/protocol/openid-connect - - TIMED_SSO_CLIENT_ID=timed-public - networks: - - timed.local - keycloak: image: jboss/keycloak:10.0.1 volumes: diff --git a/backend/docker-compose.yml b/docker-compose.yml similarity index 64% rename from backend/docker-compose.yml rename to docker-compose.yml index 24ca81337..785ad1b1a 100644 --- a/backend/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,8 @@ services: - timed.local backend: - build: . + build: + context: ./backend/ ports: - 8000:80 depends_on: @@ -25,6 +26,16 @@ services: - STATIC_ROOT=/var/www/static networks: - timed.local + + frontend: + build: ./frontend/ + ports: + - 4200:80 + environment: + - TIMED_SSO_CLIENT_HOST=http://timed.local/auth/realms/timed/protocol/openid-connect + - TIMED_SSO_CLIENT_ID=timed-public + networks: + - timed.local volumes: dbdata: diff --git a/frontend/.github/dependabot.yml b/frontend/.github/dependabot.yml deleted file mode 100644 index 7d5037dbc..000000000 --- a/frontend/.github/dependabot.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: 2 -updates: - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: "weekly" - day: "friday" - time: "12:00" - timezone: "Europe/Zurich" - - package-ecosystem: npm - directory: "/" - schedule: - interval: "weekly" - day: "friday" - time: "12:00" - timezone: "Europe/Zurich" - open-pull-requests-limit: 10 - versioning-strategy: increase diff --git a/frontend/.gitignore b/frontend/.gitignore index d10296ee0..20e20b5a0 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -19,12 +19,6 @@ testem.log *.swp *.orig -# vscode -jsconfig.json - -/.vscode/ -/.idea/ - # ember-try /.node_modules.ember-try/ /bower.json.ember-try diff --git a/frontend/.husky/commit-msg b/frontend/.husky/commit-msg index 33c4fb53c..5efdc4be6 100755 --- a/frontend/.husky/commit-msg +++ b/frontend/.husky/commit-msg @@ -1,8 +1,8 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" +# #!/bin/sh +# . "$(dirname "$0")/_/husky.sh" -# skip in CI -[ -n "$CI" ] && exit 0 +# # skip in CI +# [ -n "$CI" ] && exit 0 -# lint commit message -pnpm commitlint --edit $1 +# # lint commit message +# pnpm commitlint --edit $1 diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit index 8ba9200d6..404c80e07 100755 --- a/frontend/.husky/pre-commit +++ b/frontend/.husky/pre-commit @@ -1,8 +1,8 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" +# #!/bin/sh +# . "$(dirname "$0")/_/husky.sh" -# skip in CI -[ -n "$CI" ] && exit 0 +# # skip in CI +# [ -n "$CI" ] && exit 0 -# lint staged files -pnpm lint-staged +# # lint staged files +# pnpm lint-staged diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml deleted file mode 100644 index 2b2341598..000000000 --- a/frontend/docker-compose.yml +++ /dev/null @@ -1,43 +0,0 @@ -version: "3" - -services: - db: - image: postgres:9.4 - ports: - - 5432:5432 - volumes: - - dbdata:/var/lib/postgresql/data - environment: - - POSTGRES_USER=timed - - POSTGRES_PASSWORD=timed - - frontend: - build: - context: . - ports: - - 4200:80 - - backend: - image: ghcr.io/adfinis/timed-backend:latest - ports: - - 8000:80 - depends_on: - - db - - mailhog - environment: - - DJANGO_DATABASE_HOST=db - - DJANGO_DATABASE_PORT=5432 - - ENV=docker - - STATIC_ROOT=/var/www/static - - EMAIL_URL=smtp://mailhog:1025 - command: /bin/sh -c "wait-for-it.sh -t 60 db:5432 -- ./manage.py migrate && ./manage.py loaddata timed/fixtures/test_data.json && uwsgi" - - mailhog: - image: mailhog/mailhog - ports: - - 8025:8025 - environment: - - MH_UI_WEB_PATH=mailhog - -volumes: - dbdata: diff --git a/frontend/package.json b/frontend/package.json index cf3f08ee0..3ff5102ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "lint:hbs:fix": "ember-template-lint . --fix", "lint:js": "eslint --config .eslintrc.js .", "lint:js:fix": "eslint --config .eslintrc.js . --fix", - "prepare": "husky install", + "prepare": "cd .. && husky install frontend/.husky", "preinstall": "npx only-allow pnpm", "start": "ember server --proxy http://localhost:8000", "test": "npm-run-all test:*",