commit 49de912c1532efe1461fffad39eb883663f93038 Author: Lea Laux Date: Wed Nov 18 11:13:49 2020 +0100 Initial github release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee944b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +venv/ +virtualenv/ +.idea/ +__pycache__ +*dist/ +*build/ +*egg-info/ +*.tar.gz +*.log +connection_parameters.yaml +app_configuration.yaml +*.sql +*.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7eb2caf --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Pyadmin: Database administration tool + Copyright (C) 2020 KDV Bayern + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Pygadmin Copyright (C) 2020 KDV Bayern + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6636e5c --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Pygadmin +Pygadmin is a database administration tool written in Python with the framework Qt (PyQt) +for the user interface and psycopg2 as a database adapter. The tool is meant for the management +of PostgreSQL databases. + +## Running Pygadmin +Pygadmin is intended to be run as a normal user and with its GUI. Just run the file `__main__.py` +after the installation. + +## Requirements +* Python (3.6+) +* psycopg2 +* PyYAML +* PyQt5 +* QScintilla +* keyring + +## Installing Pygadmin +The file `setup.py` contains all the requirements. After cloning the repository, Pygadmin can +be installed with pip. Just navigate into the pygadmin folder and use `pip install .` for +installing. + diff --git a/pygadmin/__init__.py b/pygadmin/__init__.py new file mode 100644 index 0000000..f5e4e24 --- /dev/null +++ b/pygadmin/__init__.py @@ -0,0 +1,29 @@ +import signal +import sys +import os + +from PyQt5.QtWidgets import QApplication + +from pygadmin.logger import setup_logging_configuration +from pygadmin.widgets.main_window import MainWindow + +__version__ = '0a0' + + +def main(): + """ + Define a function, which describes the main part of the program, so every part is executed. + """ + + # Define the path of the configuration file os independent. + configuration_path = os.path.join(os.path.dirname(__file__), "logging.yaml") + # Get the logging configuration. + setup_logging_configuration(configuration_file_path=configuration_path) + # Enable a possibility for easy handling to end the program. + signal.signal(signal.SIGINT, signal.SIG_DFL) + # Activate the program as QApplication. + app = QApplication(sys.argv) + # Use the main window as central element of the application and the user interface. + main_window = MainWindow() + # Execute the application. + sys.exit(app.exec()) diff --git a/pygadmin/__main__.py b/pygadmin/__main__.py new file mode 100644 index 0000000..7242c2c --- /dev/null +++ b/pygadmin/__main__.py @@ -0,0 +1,4 @@ +import pygadmin + +if __name__ == "__main__": + pygadmin.main() diff --git a/pygadmin/command_history_store.py b/pygadmin/command_history_store.py new file mode 100644 index 0000000..2f3f525 --- /dev/null +++ b/pygadmin/command_history_store.py @@ -0,0 +1,170 @@ +import copy +import logging +import os + +import yaml + +from pygadmin.configurator import global_app_configurator + + +class CommandHistoryStore: + """ + Create a class for the administration of the command history. Previous SQL commands can be saved. A .yaml file is + used for the persistent storage. + """ + + def __init__(self): + # Define a path for the configuration files. + configuration_path = os.path.join(os.path.expanduser("~"), '.pygadmin') + + # If the path for the configuration files does not exist, create it. + if not os.path.exists(configuration_path): + os.mkdir(configuration_path) + + # Define the file with the command history. + self.yaml_command_history_file = os.path.join(configuration_path, "command_history.yaml") + + # Predefine a command history list as empty list. + self.command_history_list = [] + + # If the file does not exist, create it. + if not os.path.exists(self.yaml_command_history_file): + # Create the file as an empty file for writing in it. + open(self.yaml_command_history_file, "a") + + # Get the current history out of the command file. + self.get_command_history_from_yaml_file() + # Get the current command limit as activation for a deletion process. If there are too many commands in the + # history, delete the oldest ones, until the command limit is reached. + self.get_new_command_limit() + # Adjust the list to the current command limit. + self.adjust_saved_history_to_new_command_limit() + # Commit the current list to the yaml file. + self.commit_current_list_to_yaml() + + def get_command_history_from_yaml_file(self): + """ + Load the current history from the yaml file. + """ + + # Use a try statement in case of a broken .yaml file. + try: + # Use the read mode for getting the content of the file. + with open(self.yaml_command_history_file, "r") as command_history_data: + # Use the function for a safe load, because the file can be edited manually. + self.command_history_list = yaml.safe_load(command_history_data) + + # If the list for the command history from the .yaml file is empty, it is None after a load. But for + # preventing further errors, this if branch is used. + if self.command_history_list is None: + # Set the connection parameters to an empty list. + self.command_history_list = [] + + # Return a copy of the list as a result, so changes on the list like reversing do not have an effect to + # the attribute. + return copy.copy(self.command_history_list) + + except Exception as file_error: + logging.error("The file {} cannot be opened and the command history cannot be loaded with the following " + "error: {}".format(self.yaml_command_history_file, file_error), exc_info=True) + + def commit_current_list_to_yaml(self): + """ + Save the current command history list in a yaml file. + """ + + try: + # Open the .yaml file in writing mode for dumping the data. + with open(self.yaml_command_history_file, "w") as command_history_data: + yaml.safe_dump(self.command_history_list, command_history_data) + + # Report the success. + return True + + except Exception as file_error: + logging.error("The file {} cannot be opened and the command history cannot be saved with the following " + "error: {}".format(self.yaml_command_history_file, file_error)) + + # Report the failure. + return False + + def save_command_history_in_yaml_file(self, new_command_dictionary): + """ + Save a new command dictionary, so a new command with its meta data, in the command list. Check also for a + command limit and delete the potential oldest element. + """ + + # Get all current commands in the history. + self.get_command_history_from_yaml_file() + # Add the new command to the list. + self.command_history_list.append(new_command_dictionary) + + # Check for an existing command limit. + if self.command_limit is not None: + # If the length of the list is larger than the command limit, delete the oldest element. + if len(self.command_history_list) > self.command_limit: + # Delete the first element as oldest element. + del self.command_history_list[0] + + # Commit the current list to the yaml file for saving it. + self.commit_current_list_to_yaml() + + def delete_command_from_history(self, delete_command_dictionary): + """ + Delete one command, specified in a command dictionary. + """ + + # Check for the existence of the command in the command history list. + if delete_command_dictionary in self.command_history_list: + # Delete the command after a match. + self.command_history_list.remove(delete_command_dictionary) + # Return the result of the saving in the yaml file. + return self.commit_current_list_to_yaml() + + # Return False as failure. + return False + + def delete_all_commands_from_history(self): + """ + Delete all existing commands in the history. + """ + + # Define the command history list as empty list. + self.command_history_list = [] + + # Return the saving result for a success or a failure. + return self.commit_current_list_to_yaml() + + def get_new_command_limit(self): + """ + Get the new command limit out of the global app configurator. + """ + + self.command_limit = global_app_configurator.get_single_configuration("command_limit") + + def adjust_saved_history_to_new_command_limit(self): + """ + Get a new command limit and delete the old commands, if there are old commands above the new limit. + """ + + # Get the new command limit. + self.get_new_command_limit() + + # If the command limit is still None, end the function with a return. + if self.command_limit is None: + return + + # Get the number of overflow commands: The length of the list contains the number of commands and the command + # limit is the number of valid commands. + overflow_command_number = len(self.command_history_list) - self.command_limit + + # If there are overflow commands, the number of them is larger than 0. + if overflow_command_number > 0: + # Use a for loop for every overflow command. + for command_number in range(overflow_command_number): + # Delete the first element of the list: The first element is the oldest element, because there is always + # an append process to the file and to the list. + del self.command_history_list[0] + + +global_command_history_store = CommandHistoryStore() diff --git a/pygadmin/configurator.py b/pygadmin/configurator.py new file mode 100644 index 0000000..6bba575 --- /dev/null +++ b/pygadmin/configurator.py @@ -0,0 +1,253 @@ +import os +import yaml +import logging +import copy + + +class AppConfigurator: + """ + Create a class for administration of the configuration file. The application has some options, which should be + stored in a larger time delta than the runtime of the application. As a result, a .yaml file with configuration + details exists, so there are configuration options stored. They can be edited with the use of a dictionary, which + contains all the configuration options. This class can also handled cases with options, which are not set. + """ + + def __init__(self): + # Define a path for the configuration files. + configuration_path = os.path.join(os.path.expanduser("~"), '.pygadmin') + + # If the path for the configuration files does not exist, create it. + if not os.path.exists(configuration_path): + os.mkdir(configuration_path) + + # Define a configuration file. + self.yaml_app_configuration_file = os.path.join(configuration_path, "app_configuration.yaml") + self.yaml_editor_style_configuration_file = os.path.join(configuration_path, "editor_style.yaml") + + # Check for the existence of the configuration file. + if not os.path.exists(self.yaml_app_configuration_file): + # Create the file as an empty file for writing in it. + open(self.yaml_app_configuration_file, "a") + + # Check for the existence of the editor configuration file. + if not os.path.exists(self.yaml_editor_style_configuration_file): + open(self.yaml_editor_style_configuration_file, "a") + # Initialize default themes. + self.init_default_themes() + + # Load the current data at the initialization of an object. + self.load_configuration_data() + + def init_default_themes(self): + """ + Create default themes for the style configuration and dump them in an empty file. + """ + + default_themes = { + "Default": {"default_color": "ff000000", + "default_paper_color": "#ffffffff", + "keyword_color": "#ff00007f", + "number_color": "#ff007f7f", + "other_keyword_color": "#ff7f7f00", + "apostrophe_color": "#ff7f007f"}, + "Hack": {"default_color": "#59ff47", + "default_paper_color": "#141414", + "keyword_color": "#679cff", + "number_color": "#85fff7", + "other_keyword_color": "#ffe19b", + "apostrophe_color": "#f8bdff"}, + "Avocado": {"default_color": "#3a330a", + "default_paper_color": "#feffb3", + "keyword_color": "#68bd22", + "number_color": "#42ff9a", + "other_keyword_color": "#00ffc8", + "apostrophe_color": "#fff04a"} + } + + try: + with open(self.yaml_editor_style_configuration_file, "w") as default_theme_style_data: + yaml.safe_dump(default_themes, default_theme_style_data) + + except Exception as file_error: + logging.error("The file {} cannot be opened and default style information cannot be loaded with the " + "following error: {}".format(self.yaml_app_configuration_file, file_error), exc_info=True) + + def load_configuration_data(self): + """ + Load the current data about the configuration in a dictionary out of the .yaml file for storing the + configuration data. + """ + + # Use a try statement in case of a broken .yaml file. + try: + # Use the read mode for getting the content of the file. + with open(self.yaml_app_configuration_file, "r") as connection_data: + # Use the function for a safe load, because the file can be edited manually. + self.configuration_dictionary = yaml.safe_load(connection_data) + + # Use the read mode for getting the content of the file. + with open(self.yaml_editor_style_configuration_file, "r") as style_data: + self.editor_style_dictionary = yaml.safe_load(style_data) + + self.check_data_load_for_empty_data() + + # Try to get the current command limit as check for its existence. + try: + self.configuration_dictionary["command_limit"] + + # If the current command limit does not exist, set it to a predefined value. + except KeyError: + # Set the command limit to 500. + self.configuration_dictionary["command_limit"] = 500 + # Save the data in the configuration dictionary. + self.save_configuration_data() + + except Exception as file_error: + logging.error("The file {} cannot be opened and app configuration parameter cannot be loaded with the " + "following error: {}".format(self.yaml_app_configuration_file, file_error), exc_info=True) + + def check_data_load_for_empty_data(self): + """ + Check the dictionary for the configurations and the styles for emptiness and assign them to an empty dictionary + for None. + """ + + # If the loaded data is None, make an empty dictionary, so basic functions of the class can be used. + if self.configuration_dictionary is None: + self.configuration_dictionary = {} + + # If the loaded data is None, make an empty dictionary, so basic functions of the class can be used. + if self.editor_style_dictionary is None: + self.editor_style_dictionary = {} + + def save_configuration_data(self): + """ + Save the data about the configuration in the .yaml file. This function should be used after a change of + configuration parameters, which should be saved. + """ + + try: + # Open the .yaml file in writing mode for dumping the data. + with open(self.yaml_app_configuration_file, "w") as connection_data: + yaml.safe_dump(self.configuration_dictionary, connection_data) + + # Report the success. + return True + + except Exception as file_error: + logging.error("The file {} cannot be opened and configurations cannot be saved with the following error:" + " {}".format(self.yaml_app_configuration_file, file_error)) + + def get_single_configuration(self, configuration_to_check): + """ + Get a single configuration parameter by its configuration key to check in the dictionary for all parameters. + """ + + # Try to find the key in the dictionary. + try: + # Get the value out of the dictionary. + configuration_value = self.configuration_dictionary[configuration_to_check] + + # A KeyError happens, if the dictionary does not contain the given key. + except KeyError: + # Set the value to None, because there is None. + configuration_value = None + logging.info("Configuration information for {} is not set.".format(configuration_to_check)) + + # Return the result, which is the actual result or None as "not found". + return configuration_value + + def set_single_configuration(self, configuration_key, configuration_value): + """ + Set a configuration with a configuration key and a configuration value. The name of the configuration key should + be self-explaining, so it is understandable for a manual look in the .yaml file for configuration. + """ + + # Set the key and its value to the dictionary. + self.configuration_dictionary[configuration_key] = configuration_value + + def delete_single_configuration(self, configuration_key): + """ + Delete a single configuration specified by the configuration key out of the dictionary. + """ + + # Try to delete the given key out of the dictionary, because there is no further test, if the key is really part + # of the dictionary. + try: + # Delete the key. + del self.configuration_dictionary[configuration_key] + + # Return True for a success. + return True + + # Catch a potential key error for a missing key in the dictionary. + except KeyError: + # Return False for a failure. + return False + + def get_all_current_configurations(self): + """ + Return the dictionary with all current configuration data. + """ + + # Return a copy of the dictionary. + return copy.deepcopy(self.configuration_dictionary) + + def get_default_color_theme_style(self): + """ + Get the current and default color theme style. + """ + + # Get the current color theme configuration. + color_theme_configuration = self.get_single_configuration("color_theme") + + # Check fot the current content of the style dictionary. + if self.editor_style_dictionary: + # Find the match for the current configuration. + for style_description, style_parameter in self.editor_style_dictionary.items(): + # If there is a match, return it. + if style_description == color_theme_configuration: + return style_description, style_parameter + + # Return None for an empty editor style dictionary or a missing match in the dictionary. + return None + + def add_style_configuration(self, style_name, style_parameter_in_dictionary): + """ + Add a style configuration to the class-wide dictionary for all style configurations. + """ + + self.editor_style_dictionary[style_name] = style_parameter_in_dictionary + + def save_style_configuration_data(self): + """ + Save the current style configuration data in a .yaml file for further usage after the runtime of one pygadmin. + """ + + try: + # Open the .yaml file in writing mode for dumping the data. + with open(self.yaml_editor_style_configuration_file, "w") as style_data: + yaml.safe_dump(self.editor_style_dictionary, style_data) + + # Report the success. + return True + + except Exception as file_error: + logging.error("The file {} cannot be opened and database connection parameter cannot be saved with the " + "following error: {}".format(self.yaml_editor_style_configuration_file, file_error)) + + def get_all_current_color_style_themes(self, load_new_data=True): + """ + Load the current data out of the .yaml file for the newest and freshest and latest data. Return the style + dictionary for all color styles. + """ + + # If new data is required (which is the default), load all the data again out of the .yaml files. + if load_new_data: + self.load_configuration_data() + + # Return a copy of the dictionary. + return copy.deepcopy(self.editor_style_dictionary) + + +global_app_configurator = AppConfigurator() diff --git a/pygadmin/connectionfactory.py b/pygadmin/connectionfactory.py new file mode 100644 index 0000000..b579b16 --- /dev/null +++ b/pygadmin/connectionfactory.py @@ -0,0 +1,198 @@ +import psycopg2 +import keyring +import logging +import re + + +class ConnectionFactory: + """ + Create a class for administration of connections used in the application. The class saves a connection as a value + with its identifier as key, which is based on the given user, the given host and the given database. + """ + + def __init__(self): + self.connections_dictionary = {} + self.service_name = "Pygadmin" + + def get_database_connection(self, host, user, database, port=5432, timeout=10000): + """ + Establish a database connection based on a given host, user, database and port, which is mostly port 5432. Use a + timeout, so after this time, a query is cancelled. If there is already a connection for the specific identifier + in the connection dictionary, use the connection. If there is none, establish a new one. + """ + + # A connection identifier is used as a unique key to identify a user on a specific server and database. + connection_identifier = "{}@{}:{}/{}".format(user, host, port, database) + # Create a password identifier for recognizing the database connection in the password manager. It is not + # necessary to specify the database. So the connection identifier is split at the slash. The database follows + # after the slash, so the first item of the split is taken as password identifier. + password_identifier = connection_identifier.split("/")[0] + + # If a connection identifier exists in the connection dictionary, there is not need for establishing a new + # connection. + if connection_identifier in self.connections_dictionary: + # Get the connection by its key, the connection identifier. + database_connection = self.connections_dictionary[connection_identifier] + + # This elif branch is used for error handling in one specific case. If the check for a password for a user + # returns None, the user is unknown in the password manager. + elif keyring.get_password(self.service_name, password_identifier) is None: + # Set the database connection to None as a part of error handling. There can be a case differentiation + # between a database connection with the value None and the value False. + database_connection = None + + logging.error("Identifier {} unknown in the password manager.".format(password_identifier)) + + else: + # Use a try except statement for potential psycopg2 errors while the connection is being established. + try: + # Use an url with the connection identifier for a connection. + postgres_url = "postgresql://{}".format(connection_identifier) + # Use an parameter for the connection with a timeout. + timeout_description = "-c statement_timeout={}".format(timeout) + # Establish a connection with the url, the password in the keyring and the timeout as option. + database_connection = psycopg2.connect(postgres_url, + password=keyring.get_password(self.service_name, + password_identifier), + options=timeout_description) + + # Set the autocommit option to True, so changes are immediately, once a cursor is used. + database_connection.autocommit = True + + # Save the connection in the dictionary for potential later use. + self.connections_dictionary[connection_identifier] = database_connection + + logging.info("Connection with identifier {} established.".format(connection_identifier)) + + except psycopg2.OperationalError as connection_error: + # Set the database connection to False as a sign for an occurred error. Because .pgerror does not seem + # to work, this is a general error. There are approximately 61 different problems causing this error. + # A mapping is nearly impossible without the code because the error messages are translated to the + # language of the system. + database_connection = False + logging.error("Connection with database {} failed. For further information, this exception occurred: " + "{}".format(database, connection_error), exc_info=True) + + # At this point, the database connection can contain a connection or a value with None or False. This makes + # error handling possible in every function and objects, which uses this factory. + return database_connection + + def get_database_connection_parameters(self, database_connection): + """ + Use a given database connection to find the related database connection parameters. If one is found, get the + database connection identifier and create a dictionary with the parameters and return it. If not, return None. + """ + + # Define a container for the results of the following statements. + database_parameter_dictionary = None + + # Check every key and value pair in the dictionary, where all connections and their identifier are stored. + for stored_database_connection_identifier, stored_database_connection in self.connections_dictionary.items(): + # Check if a stored connection is the given database connection. + if stored_database_connection == database_connection: + # Save the identifier of the found connection. + database_connection_identifier = stored_database_connection_identifier + # Create a list of the parameters out of the identifier. The identifier has three different characters + # for separation, @, : and /. These lead to four different elements in the resulting list. + parameter_list = [parameter.strip() for parameter in re.split("[@:/]", database_connection_identifier)] + # Save the parameters in a dictionary. + database_parameter_dictionary = { + "user": parameter_list[0], + "host": parameter_list[1], + # Cast the port to an integer for preventing weird behavior. + "port": int(parameter_list[2]), + "database": parameter_list[3] + } + + return database_parameter_dictionary + + def test_parameters_for_database_connection(self, host, user, database, password, port=5432): + """ + Test given database connection parameters for their validity, so they can used for a valid database connection. + Check, if these parameters are already part of the dictionary and if they are, there is a valid connection. If + not, try to establish a database connection. + """ + + # Create a connection identifier for establishing a connection. + connection_identifier = "{}@{}:{}/{}".format(user, host, port, database) + + # Check for the identifier in the dictionary for all connections. If the identifier is found there as a key, the + # connection is valid, because the dictionary contains all current valid connections. + if connection_identifier in self.connections_dictionary: + return True + + # Try to establish a database connection. + try: + # Create a postgres url, so not every parameters is separated as part for the connection. + postgres_url = "postgresql://{}".format(connection_identifier) + # Use the url and the password for a database connection. + psycopg2.connect(postgres_url, password=password) + + # Congratulations, if this value is returned, the database connection is valid. + return True + + # Use an except case, if the database connection failed. + except psycopg2.OperationalError as database_error: + # Save at least a specific error in den log. + logging.error("Test database connection for identifier {} failed with the error {}".format( + connection_identifier, database_error), exc_info=True) + + return False + + def close_and_remove_database_connection(self, database_connection): + """ + Get a database connection and close this connection. Find also the corresponding identifier in the dictionary + for deleting this identifier with its connection. + """ + + # Define a variable for the connection identifier match in the dictionary. After a checkup, this variable should + # contain the connection identifier as key to the given database connection as value. + connection_identifier_match = None + + # Check the dictionary with all connections with its keys and values as identifiers and connections. + for connection_identifier, saved_database_connection in self.connections_dictionary.items(): + # Check for the given database in the dictionary. + if saved_database_connection == database_connection: + # Assign the connection identifier as key to the match variable. + connection_identifier_match = connection_identifier + + # Check for a correct match. + if connection_identifier_match is not None: + # Close the given database connection. + database_connection.close() + # Remove the database connection from the dictionary with its identifier. + del self.connections_dictionary[connection_identifier_match] + # Save an information in the log. + logging.info("Connection with identifier {} closed.".format(connection_identifier_match)) + + # Return True for a success. + return True + + def reestablish_terminated_connection(self, connection_parameters): + """ + Get connection parameters and find the relevant database connection. Close the connection and reestablish a + database connection. Return the new connection. + """ + + # Get the database connection out of the class-wide dictionary with the connection parameters. + connection_in_dictionary = self.get_database_connection(connection_parameters["host"], + connection_parameters["user"], + connection_parameters["database"], + connection_parameters["port"]) + + # If the connection is really a connection and not an error, proceed. + if isinstance(connection_in_dictionary, psycopg2.extensions.connection): + # Close and remove the current connection, because the connection should be reestablished. This is realized + # by a re-connect. + if self.close_and_remove_database_connection(connection_in_dictionary): + # Re-connect with the given connection parameters. + new_database_connection = self.get_database_connection(connection_parameters["host"], + connection_parameters["user"], + connection_parameters["database"], + connection_parameters["port"]) + + # Return the fresh and new database connection. + return new_database_connection + + +global_connection_factory = ConnectionFactory() diff --git a/pygadmin/connectionstore.py b/pygadmin/connectionstore.py new file mode 100644 index 0000000..3c79eca --- /dev/null +++ b/pygadmin/connectionstore.py @@ -0,0 +1,221 @@ +import copy +import logging +import os + +import yaml + + +class ConnectionStore: + """ + Create a class for administration of database connection parameters, which are stored in a .yaml file. It is + necessary to save connections after the runtime of the program, so connections can be established without entering + them every time the application starts. + """ + + def __init__(self): + # Define a path for the configuration files. + configuration_path = os.path.join(os.path.expanduser("~"), '.pygadmin') + + # If the path for the configuration files does not exist, create it. + if not os.path.exists(configuration_path): + os.mkdir(configuration_path) + + # Define the yaml file, which stores the database connection parameters, so it is independent from the user's + # operating system. + self.yaml_connection_parameters_file = os.path.join(configuration_path, "connection_parameters.yaml") + # Create a container list for the data of connection_parameters.yaml and the data, which is going to be dumped + # in the file. + self.connection_parameters_yaml = [] + + # Check for the existence of the file. + if not os.path.exists(self.yaml_connection_parameters_file): + # Create the file as an empty file for writing in it. + open(self.yaml_connection_parameters_file, "a") + + def get_connection_parameters_from_yaml_file(self): + """ + Read all the different connection parameters from a .yaml file and store them in a list for further usage. + """ + + # Use a try statement in case of a broken .yaml file. + try: + # Use the read mode for getting the content of the file. + with open(self.yaml_connection_parameters_file, "r") as connection_data: + # Use the function for a safe load, because the file can be edited manually. + self.connection_parameters_yaml = yaml.safe_load(connection_data) + + # If the list for the connections parameters out of the .yaml file is empty, it is None after a load. + # But for preventing further errors, this if branch is used. + if self.connection_parameters_yaml is None: + # Set the connection parameters to an empty list. + self.connection_parameters_yaml = [] + + # Return a copy of the connection parameters list, so there is no manipulation from the outside. + return copy.copy(self.connection_parameters_yaml) + + except Exception as file_error: + logging.error("The file {} cannot be opened and database connection parameter cannot be loaded with the " + "following error: {}".format(self.yaml_connection_parameters_file, file_error), exc_info=True) + + def save_connection_parameters_in_yaml_file(self, connection_parameter_dictionary): + """ + Save given connection parameters in a dictionary in the .yaml file for all connection parameters after a check + for a duplicate. A dictionary instead of a connection identifier is chosen, because it is more readable end + editable for a human. + """ + + # Ensure the load of all the current connection parameters, so a later dump in connection_parameters.yaml + # contains all parameters and none is lost. + self.get_connection_parameters_from_yaml_file() + + # The list of dictionaries for is None, if the .yaml file is empty and does not contain any connections. + if self.connection_parameters_yaml is None: + # Set the storage variable for the connection parameter to an empty list for appending later. + self.connection_parameters_yaml = [] + + if self.check_for_correct_keys_in_dictionary(connection_parameter_dictionary) is False: + return False + + # Use the function to check for a duplicate, which returns True, if the connection already exists. + if self.check_parameter_for_duplicate(connection_parameter_dictionary) is True: + logging.warning("The given parameter already exists in {}.".format(self.yaml_connection_parameters_file)) + # End the current function, because saving the connection parameters is not necessary at this point. + return False + + # Append the given connection parameters in a dictionary to the list of all connection parameters. + self.connection_parameters_yaml.append(connection_parameter_dictionary) + + return self.commit_current_list_to_yaml() + + def commit_current_list_to_yaml(self): + """ + Save the current list with its changes in the .yaml file. + """ + + try: + # Open the .yaml file in writing mode for dumping the data. + with open(self.yaml_connection_parameters_file, "w") as connection_data: + yaml.safe_dump(self.connection_parameters_yaml, connection_data) + + # Report the success. + return True + + except Exception as file_error: + logging.error("The file {} cannot be opened and database connection parameter cannot be saved with the " + "following error: {}".format(self.yaml_connection_parameters_file, file_error)) + + def check_parameter_for_duplicate(self, connection_parameter_dictionary_for_check): + """ + Check if the given dictionary with connection parameters is already part of the list, which stores the + parameters of and for the .yaml file. If the host, the user and the port are identical, the database is + irrelevant, because all connections to accessible databases are established with only one database. This one + database is used as entry point. + """ + + # Check every element in the given dictionary and the already stored connections. + for connection_parameter_dictionary in self.connection_parameters_yaml: + # Check for identical user, host and port. + if connection_parameter_dictionary["Username"] == connection_parameter_dictionary_for_check["Username"] \ + and connection_parameter_dictionary["Host"] == connection_parameter_dictionary_for_check["Host"] \ + and connection_parameter_dictionary["Port"] == connection_parameter_dictionary_for_check["Port"]: + return True + + # If a duplicate is not found, this else branch is active. + else: + # Return False for unique parameters in host, port and user, because it is not a duplicate. + return False + + @staticmethod + def check_for_correct_keys_in_dictionary(dictionary_to_check): + """ + Get a dictionary with the connection parameters and check for the right keys: Host, Username, Database and Port. + """ + + if "Host" in dictionary_to_check and "Username" in dictionary_to_check and "Database" in dictionary_to_check \ + and "Port" in dictionary_to_check: + return True + + else: + return False + + def delete_connection(self, connection_dictionary): + """ + Delete a connection with its given parameters. The parameters are given in a dictionary. A check is required, so + only if the connection really exists, it is deleted. + """ + + # Check for the given dictionary in the list. + if connection_dictionary in self.connection_parameters_yaml: + # Remove the dictionary in the list of all connection parameter dictionaries. + self.connection_parameters_yaml.remove(connection_dictionary) + # Save the current list with the deleted dictionary and return the result as result of the success of + # committing the current list. + return self.commit_current_list_to_yaml() + + # Return False for a failure. + return False + + def change_connection(self, old_connection_dictionary, new_connection_dictionary, password_change=False): + """ + Change the connection parameters in the class-wide list for all connections. Use the old connection and find it + in the list for all connections. Then, replace the old connection with the new. Save the changes in the .yaml + file. Use the parameter for a changed password, so if only the password is changed, a duplicate can be found. + """ + + # Check for the correct keys in the new dictionary before inserting them. + if self.check_for_correct_keys_in_dictionary(new_connection_dictionary) is False: + return False + + # Proceed, if the password is changed or a duplicate is not found. If the password changed, a duplicate is + # irrelevant. But there could also be the change of a password and another parameter. + if password_change is True or self.check_parameter_for_duplicate(new_connection_dictionary) is False: + # Use a list comprehension to replace the old dictionary with the new one. For this operation, it is + # necessary to find the old dictionary in the list of all connection dictionaries. + self.connection_parameters_yaml[:] = [ + new_connection_dictionary if connection_dictionary == old_connection_dictionary + else connection_dictionary for connection_dictionary in self.connection_parameters_yaml + ] + + # Commit the new list to the .yaml file and return the result. + return self.commit_current_list_to_yaml() + + # Return False for a failure. + return False + + def get_number_of_connection_parameters(self): + """ + Get the current number of existing dictionaries with connection parameters. The class-wide list contains the + latest updates and is identical with the connections in the .yaml file. + """ + + return len(self.connection_parameters_yaml) + + def get_index_of_connection(self, connection_parameters_dictionary_to_check): + """ + Get a dictionary with database connection parameters to check for the index in the current list. + """ + + # Check for every item in the list for a match. + for index in range(len(self.connection_parameters_yaml)): + # If the list of all dictionaries contains the given dictionary, there is a match. + if self.connection_parameters_yaml[index] == connection_parameters_dictionary_to_check: + + # Return the current index of the connection. + return index + + def get_connection_at_index(self, connection_at_index): + """ + Use a given index to get a connection at this given index. + """ + + # Use the given index for getting the related connection. + try: + connection_at_index = self.connection_parameters_yaml[connection_at_index] + return connection_at_index + + # Return None for an index out of the bounds of the list. + except IndexError: + return None + + +global_connection_store = ConnectionStore() diff --git a/pygadmin/database_dumper.py b/pygadmin/database_dumper.py new file mode 100644 index 0000000..6d8a399 --- /dev/null +++ b/pygadmin/database_dumper.py @@ -0,0 +1,270 @@ +import os +import subprocess +import tempfile +import re +import keyring +import logging + +from pygadmin.configurator import global_app_configurator + + +class DatabaseDumper: + """ + Create a class, which is responsible for and capable of database dumps. This class is required for getting the + create statement of a database. + """ + + def __init__(self, user, database, host, port, dump_information, information_name=None): + """ + Get the relevant parameters, which specify the connection and the database for a dump. The dump information + contains the type of information for the dump, so this includes databases, tables and views. The information + name contains the name of the view or table. + """ + + self.user = user + self.database = database + self.host = host + self.port = port + self.dump_information = dump_information + self.information_name = information_name + self.pg_dump_statement = None + self.service_name = "Pygadmin" + # Define a pg dump path as None, so the path can be set later. + self.pg_dump_path = None + + def dump_database(self): + """ + Dump the database with a previous check for a password. Use a password file for submitting the password for the + dump, which is specified in the environment variable for the PGPASSFILE. + """ + + # Create a password identifier out of the username, host and port for checking the password for this identifier + # in the keyring. + password_identifier = "{}@{}:{}".format(self.user, self.host, self.port) + + # If the password in the keyring is not None, proceed. If the password were None, there would be a missing + # password for the given identifier. The password is necessary for the following steps. + if keyring.get_password(self.service_name, password_identifier) is not None: + # Create a temporary pass file and get the file handler and path. + file_handler, file_path = self.create_pass_file(password_identifier) + + # Get a copy of the current process environment with its specified variables as a dictionary. + process_env = os.environ.copy() + # Set the file path of the created file as PGPASSFILE. + process_env['PGPASSFILE'] = file_path + + # Get the path of the pg_dump executable. + self.get_pg_dump_path() + + # Get the statement for the dump. + self.get_pg_dump_statement() + + # Define a variable for the result, which is returned later. + result = None + + # Try to dump the database. + try: + # Use a subprocess for the dump. Use the pg_dump statement and capture the output. The environment is + # set as predefined process environment, which contains a PGPASSFILE. A timeout is set, so after 30 + # seconds, the subprocess is canceled. stdout defines the output place and is used for Python 3.6 + # compatibility. Universal newlines are used for a more "beautiful" output. + result = subprocess.run(self.pg_dump_statement, env=process_env, timeout=30, stdout=subprocess.PIPE, + universal_newlines=True) + + # If an exception occurs, for example triggered by a timeout, save a message in the log. + except Exception as error: + logging.error("An error occurred during the dumping process: {}".format(error)) + + # Use a finally block for a short clean up. + finally: + # Remove the file, which was used for submitting the password and is no longer necessary. + os.remove(file_path) + # Return the result. + return result + + def create_pass_file(self, password_identifier): + """ + Get the password identifier and create a pass file, which is used for the login process in pg_dump. + """ + + # Create a secure temporary file with a file handler and a path. + file_handler, file_path = tempfile.mkstemp() + + # Open the file with its path in the writing mode. + with open(file_path, "w") as file: + # Write the relevant data for a pass file into the file. This construction is recommended by the + # documentation of PostgreSQL. + file.write("{}:{}:{}:{}:{}".format(self.host, self.port, self.database, self.user, + keyring.get_password(self.service_name, password_identifier))) + + # Return the information about the temporary file. + return file_handler, file_path + + def get_pg_dump_path(self): + """ + Get the path for the executable pg_dump out of the configuration file. Check the path for its existence. + """ + + # Get the path out of the configuration file. + pg_dump_path = global_app_configurator.get_single_configuration("pg_dump_path") + + # Check for the existence of the path. The path is None, if there is no such configuration. If there is a path, + # check for its existence with os. + if pg_dump_path is not None and os.path.exists(pg_dump_path): + # Use the given path as executable. + self.pg_dump_path = pg_dump_path + + # If the pg_dump path does not exist or cannot be found, use the default pg_dump. + else: + self.pg_dump_path = "pg_dump" + + def get_pg_dump_statement(self): + """ + Get the pg_dump statement based on the type of dump information. The statement for getting the create statement + of a database contains different arguments compared to the arguments for the create statement of a table or + view. + """ + + # Create the statement for a database. + if self.dump_information == "Database": + # Exclude all tables and views with their specific information. + table_view_statement = '-T*' + # Show the create statement of the database. + create_statement = '--create' + + # Create the statement for a table or view. + else: + # Define the name of the table or view. The parameter --table includes views, materialized views, sequences, + # and foreign tables in this case according to the documentation of pg_dump. + table_view_statement = "--table={}".format(self.information_name) + # Exclude the data in the tables, so only the schema is dumped. + create_statement = '--schema-only' + + # Create a connection identifier for the usage in the pg_dump statement. + connection_identifier = "postgresql://{}@{}:{}/{}".format(self.user, self.host, self.port, self.database) + + # Define the different parameters for the dump. First, there is the call of the pre defined pg_dump + # executable, followed by the parameter with the table specifications. The create statement contains the + # parameter for the definition without data. + self.pg_dump_statement = [self.pg_dump_path, table_view_statement, create_statement, '--dbname={}'.format( + connection_identifier)] + + def dump_database_and_clean_result(self): + """ + Dump the database with the function for dumping the database and clean the result, so the result contains the + relevant data in a list. The relevant data is based on the dump information. + """ + + # Dump the database. + dump_result = self.dump_database() + + # Check, if the dump result is not None, so there was a successful dump. + if dump_result is not None: + # Get the result of the dump in separate lines in a list. + dump_result_lines = dump_result.stdout.split(os.linesep) + + # If the dump is used for the create statement of a database, use the function for cleaning the result for + # a database. + if self.dump_information == "Database": + return self.clean_database_result(dump_result_lines) + + # Use the function for cleaning a table result, if the relevant information is for a table. + elif self.dump_information == "Table": + return self.clean_table_result(dump_result_lines) + + # Use the function for cleaning a table result, if the relevant information is for a table. + elif self.dump_information == "View": + return self.clean_view_result(dump_result_lines) + + @staticmethod + def clean_database_result(dump_result_line_list): + """ + Clean the results, which are used for the create statement of a database. + """ + + # If the dump result line list has the wrong type, return None. + if not isinstance(dump_result_line_list, list): + return None + + # Define an empty list for the results. + cleaned_result_lines = [] + + # Check every line. + for line in dump_result_line_list: + # Check, if the line contains a CREATE or ALTER as relevant information for a CREATE DATABASE + # statement. + if re.search("CREATE|ALTER", line): + # Append a match to the result list. + cleaned_result_lines.append(line) + + return cleaned_result_lines + + @staticmethod + def clean_table_result(dump_result_line_list): + """ + Clean the results, which are used for the create statement of a table. The number of open and closed brackets is + relevant for these information. + """ + + # Return None the wrong type. + if not isinstance(dump_result_line_list, list): + return None + + # Define a variable for the results. + cleaned_result_lines = [] + # Define a variable for the bracket count. This is like a non-empty stack. The variable is incremented for a ( + # and decremented for a ), so if the number of ( is equal to the number of ), the create statement ends. + bracket_count = 0 + + # Check every line. + for line in dump_result_line_list: + # Search for a CREATE TABLE or check the bracket count. + if re.search("CREATE TABLE", line) or bracket_count != 0: + # Append the result in a more readable way. + cleaned_result_lines.append(line + "\n") + + # Check every character in the line for an occurrence of ( or ) + for character in line: + # If the line contains a (, increment. + if character == "(": + bracket_count += 1 + + # If the line contains a ), decrement. + if character == ")": + bracket_count -= 1 + + return cleaned_result_lines + + @staticmethod + def clean_view_result(dump_result_line_list): + """ + Clean the results, which are used for the create statement of a table. The create view statement does not + contain brackets, so after a CREATE VIEW, there is the check for a semicolon. + """ + + # Return None for the wrong instance of the input list. + if not isinstance(dump_result_line_list, list): + return None + + # Define a list for the results. + cleaned_result_lines = [] + # Create a variable, which is set to True for the first occurrence of CREATE VIEW. + create_view = False + + # Check every line. + for line in dump_result_line_list: + # Search CREATE VIEW or proceed for after the variable for create view is set to True, so a create view + # statement begins, but it has not reached the end. + if re.search("CREATE VIEW", line) or create_view is True: + # Append the line in a more readable way. + cleaned_result_lines.append(line + "\n") + # Create view is reached or should be continue, because the statement has not reached the end. + create_view = True + + # Check every line for a semicolon. + for character in line: + if character == ";": + # Set the create view boolean to False, so the loop ends, because the statement ends. + create_view = False + + return cleaned_result_lines diff --git a/pygadmin/database_query_executor.py b/pygadmin/database_query_executor.py new file mode 100644 index 0000000..c1a6439 --- /dev/null +++ b/pygadmin/database_query_executor.py @@ -0,0 +1,297 @@ +import psycopg2 +import logging + +from PyQt5.QtCore import QObject, pyqtSignal, QRunnable, pyqtSlot, QThreadPool + +from pygadmin.configurator import global_app_configurator +from pygadmin.connectionfactory import global_connection_factory + + +class QueryWorkerSignals(QObject): + """ + Define a class of signals for QueryWorker, because QRunnable is not a QObject and cannot be used with own, + customized signals. + """ + + # Define a signal for the result data in a list, which can be used by the table model. + result_data = pyqtSignal(list) + # Define a signal for a potential error. It contains a tuple of strings for the error title and the error message. + error = pyqtSignal(tuple) + # Define a signal for the query status message, which is processed in different ways. + query_status_message = pyqtSignal(str) + + +class QueryWorker(QRunnable): + """ + Define a query worker as QRunnable for executing a query as own thread with help of the thread pool. This makes + other GUI operations, like showing the current status of query execution, possible. + """ + + def __init__(self, function_to_execute, query_text, database_cursor, parameter): + """ + Get the function for executing, the text of the query and the database cursor for executing of the function with + its parameters. Use also the signals of the class QueryWorkerSignals. + """ + + super().__init__() + # Define the function for executing. + self.function_to_execute = function_to_execute + # Define the text of the query. + self.query_text = query_text + # Define the database cursor. + self.database_cursor = database_cursor + # Define the database query parameter. + self.parameter = parameter + # Get the signals with the helper class. + self.signals = QueryWorkerSignals() + + @pyqtSlot() + def run(self): + """ + Define the function for running the relevant task, in this case executing the query. Try to run the function + and define the workarounds for corner cases and exceptions. Emit the results with the signals. + """ + + # Define the result data list as empty list, which is used for errors. + result_data_list = [] + # Define a query status message as None. + query_status_message = None + + # Try to execute the query with the given function. + try: + # Get the result of the function. + result = self.function_to_execute(self.query_text, self.database_cursor, self.parameter) + # The first parameter is the data list. + result_data_list = result[0] + # The second parameter is the query status message. + query_status_message = result[1] + + # At this point, the exception is mostly an SQL error, so an error is submitted. + except Exception as sql_error: + # Emit an error with the title and the description. + self.signals.error.emit(("SQL Error", str(sql_error))) + + # In the end, emit all relevant signals. + finally: + # Emit the result data list. + self.signals.result_data.emit(result_data_list) + # Emit the query status message. + self.signals.query_status_message.emit(query_status_message) + + +class DatabaseQueryExecutor(QObject): + """ + Create a class for executing database queries, which is a child of QObject for the usage of slots and signals. + """ + + # Define a signal for the result data in a list, which can be used by the table model. + result_data = pyqtSignal(list) + # Define a signal for a potential error. It contains a tuple of strings for the error title and the error message. + error = pyqtSignal(tuple) + # Define a signal for the query status message, which is processed in different ways. + query_status_message = pyqtSignal(str) + # Define a signal for a refresh in the database connection. The database connection is for a failed connection a + # boolean, so the possible types of the objects are bool and psycopg2.extensions.connection, so object is chosen for + # the signal. + new_database_connection = pyqtSignal(object) + + def __init__(self): + super().__init__() + # Define the database connection, the database query and the database parameter as None for further usage and + # overwrite. + self.database_connection = None + self.database_query = None + self.database_query_parameter = None + # Define a thread pool for the usage of different threads. + self.thread_pool = QThreadPool() + + def submit_and_execute_query(self): + """ + Check the connection for its validity and execute the query with the function for executing the query with a + thread. + """ + + # Check the current database connection for its validity. This if-branch is used for an invalid connection. + if self.check_for_valid_connection_and_reestablish() is False: + # Emit an empty data list as error. + self.result_data.emit([]) + # Emit an error to the user in a tuple. + self.error.emit(("Connection Error", "The current database connection is invalid and cannot be used. " + "Further information can be found in the log. This error occurs " + "normally for a failed connection to the database server, which " + "occurred during the usage of pygadmin.")) + # End the function with a return. + return + + # Create a query worker with the function for executing, the text of the query for executing and the relevant + # database cursor. + query_worker = QueryWorker(self.execute_query, self.database_query, self.database_connection.cursor(), + self.database_query_parameter) + # Connection the signal for the resulting data with the function for refreshing the table model with the fresh, + # new data. + query_worker.signals.result_data.connect(self.emit_result_data) + # Connect a potential error to the function for error processing. The parameter of the title and the text as + # tuple are used. + query_worker.signals.error.connect(self.emit_error) + # Connect the signal for the query status message to the function for checking and processing this message. + query_worker.signals.query_status_message.connect(self.emit_query_status_message) + # Start the thread. + self.thread_pool.start(query_worker) + + @staticmethod + def execute_query(query_to_execute, given_database_cursor, parameter=None): + """ + Define a static method for executing the query. Use a query and a database cursor for processing. This method is + static, because it is used in a separate thread. An explicit check for errors or try-except statements are not + used, because the wrapper thread function is used for this kind of error handling with its signals. + """ + + # Use the given database cursor. + with given_database_cursor as database_cursor: + # Execute the determined query. + database_cursor.execute(query_to_execute, parameter) + # Get the status message of the query for further usage. + query_status_message = database_cursor.statusmessage + + # If the cursor has a description, there is a data output, for example after a select statement with + # existing tables and table data. + if database_cursor.description: + # Store the data result for the header/column data in a list. + query_result_data_list = [[description[0] for description in database_cursor.description]] + # Get all the plain result data. + database_query_output = database_cursor.fetchall() + + # Iterate over every element in the output list and append every element/row of the result data to the + # general result list to combine column data and row data. + for result_element in database_query_output: + query_result_data_list.append(result_element) + + # If the description of the database cursor is not given, this is mostly the result of a database query + # without a data request like an INSERT or a CREATE statement. + else: + query_success = "Query successful: {}".format(query_status_message) + # Communicate the success of such a query without data result. + query_result_data_list = [["Status"], (query_success,)] + + # Return the result data list and the query status message for further usage. + return query_result_data_list, query_status_message + + def cancel_current_query(self): + """ + Cancel the current query. + """ + + # Use the function for cancelling the current operation/query of a database connection. If there is not a + # current query, nothing happens. + self.database_connection.cancel() + + def check_for_valid_connection_and_reestablish(self): + """ + Check for a currently valid database connection and reestablish a new one for an error. + """ + + # Check for a valid connection with the class function for checking. + valid_connection = self.is_connection_valid() + + # If the connection is valid, everything is okay and nothing else is necessary. + if valid_connection: + # Return True for a success and a valid, functional connection. + return True + + # Reestablish the connection with the given + self.reestablish_connection() + + # Check, if the new connection is really a psycopg2 database connection. The database connection could also + # be a boolean for a failed connection. The connection can also be closed or broken, so there is a check for a + # closed database connection. + if isinstance(self.database_connection, psycopg2.extensions.connection) \ + and self.database_connection.closed == 0: + # Return True for a valid connection. + return True + + # Return False, if the previous statements do not lead to a valid and functional connection. In this case, a + # connection cannot be reestablished. + return False + + def reestablish_connection(self): + """ + Use a function for reestablishing the database connection. + """ + + database_parameter_dictionary = global_connection_factory.get_database_connection_parameters( + self.database_connection) + + # Check, if the dictionary for the connection is not None. If there is no match for the database connection in + # the connection factory, the database parameter dictionary is None. + if database_parameter_dictionary is not None: + # Get the current timeout time. + timeout_time = global_app_configurator.get_single_configuration("Timeout") + + # If the timeout time is None, a configuration is not set and 10000 is used. + if timeout_time is None: + timeout_time = 10000 + + # Add the timeout to the parameter dictionary. + database_parameter_dictionary["timeout"] = timeout_time + + # Get a new database connection with the try of reestablishing the latest valid database connection. + self.database_connection = global_connection_factory.reestablish_terminated_connection( + database_parameter_dictionary) + + # Emit the new database connection. + self.new_database_connection.emit(self.database_connection) + + def is_connection_valid(self): + """ + Check for a valid connection. A connection is valid, if the variable, which should contain a connection, + contains a connection. It can also contain "False" or "None" for an invalid connection. If the connection is + also open, it is a valid connection. + """ + + # Check, if the variable for a connection really contains a psycopg2 connection. + if isinstance(self.database_connection, psycopg2.extensions.connection): + # Check for an open connection. If .closed is 0, the connection is open. If .closed is 1, the connection is + # closed. The attribute works only for a connection, which is closed by Python. + if self.database_connection.closed == 0: + # Check, if the connection is really open or closed by the PostgreSQL server. + try: + # Use the cursor of the database connection. + with self.database_connection.cursor() as database_cursor: + # Execute a test query. If the test query passes, the connection is valid. + database_cursor.execute("SELECT 42;") + + # Return True for a valid connection. + return True + + # Use an exception for the connection error. + except Exception as connection_error: + # Write a message about the error in the log. + logging.error("The current connection is not valid and produces the following error: {}".format( + connection_error), exc_info=True) + + # Return False as default, because if the method reaches this point, the connection is invalid. + return False + + @pyqtSlot(list) + def emit_result_data(self, result_data): + """ + Emit the result data list. This function is used for a transfer. + """ + + self.result_data.emit(result_data) + + @pyqtSlot(tuple) + def emit_error(self, error): + """ + Emit the error. This function is used for a transfer. + """ + + self.error.emit(error) + + @pyqtSlot(str) + def emit_query_status_message(self, query_status_message): + """ + Emit the query status message. This function is used for a transfer. + """ + + self.query_status_message.emit(query_status_message) diff --git a/pygadmin/icons/database.svg b/pygadmin/icons/database.svg new file mode 100644 index 0000000..44f9e84 --- /dev/null +++ b/pygadmin/icons/database.svg @@ -0,0 +1 @@ +database \ No newline at end of file diff --git a/pygadmin/icons/editor.svg b/pygadmin/icons/editor.svg new file mode 100644 index 0000000..0f2c347 --- /dev/null +++ b/pygadmin/icons/editor.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pygadmin/icons/execute.svg b/pygadmin/icons/execute.svg new file mode 100644 index 0000000..ee193f6 --- /dev/null +++ b/pygadmin/icons/execute.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/pygadmin/icons/history.svg b/pygadmin/icons/history.svg new file mode 100644 index 0000000..921ae50 --- /dev/null +++ b/pygadmin/icons/history.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/pygadmin/icons/load.svg b/pygadmin/icons/load.svg new file mode 100644 index 0000000..c8cc088 --- /dev/null +++ b/pygadmin/icons/load.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pygadmin/icons/pygadmin.svg b/pygadmin/icons/pygadmin.svg new file mode 100644 index 0000000..a499ace --- /dev/null +++ b/pygadmin/icons/pygadmin.svg @@ -0,0 +1,736 @@ + + + + diff --git a/pygadmin/icons/save.svg b/pygadmin/icons/save.svg new file mode 100644 index 0000000..e8febed --- /dev/null +++ b/pygadmin/icons/save.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pygadmin/icons/server.svg b/pygadmin/icons/server.svg new file mode 100644 index 0000000..176a151 --- /dev/null +++ b/pygadmin/icons/server.svg @@ -0,0 +1 @@ +server \ No newline at end of file diff --git a/pygadmin/icons/server_invalid.svg b/pygadmin/icons/server_invalid.svg new file mode 100644 index 0000000..2eb0ff0 --- /dev/null +++ b/pygadmin/icons/server_invalid.svg @@ -0,0 +1,126 @@ + + + + + + image/svg+xml + + + + + + + + + server + + + + + + + + + + + + + + + + diff --git a/pygadmin/icons/server_pending.svg b/pygadmin/icons/server_pending.svg new file mode 100644 index 0000000..8a219df --- /dev/null +++ b/pygadmin/icons/server_pending.svg @@ -0,0 +1,150 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + server + + + + + + + + + + + + + + + diff --git a/pygadmin/icons/server_valid.svg b/pygadmin/icons/server_valid.svg new file mode 100644 index 0000000..bf05908 --- /dev/null +++ b/pygadmin/icons/server_valid.svg @@ -0,0 +1,113 @@ + + + + + + image/svg+xml + + server + + + + + + + + server + + + + + + + + + + + + + diff --git a/pygadmin/icons/table.svg b/pygadmin/icons/table.svg new file mode 100644 index 0000000..8848103 --- /dev/null +++ b/pygadmin/icons/table.svg @@ -0,0 +1 @@ +table \ No newline at end of file diff --git a/pygadmin/icons/view.svg b/pygadmin/icons/view.svg new file mode 100644 index 0000000..4c63b1c --- /dev/null +++ b/pygadmin/icons/view.svg @@ -0,0 +1 @@ +view \ No newline at end of file diff --git a/pygadmin/logger.py b/pygadmin/logger.py new file mode 100644 index 0000000..f4bb52a --- /dev/null +++ b/pygadmin/logger.py @@ -0,0 +1,39 @@ +import os +import logging.config +import yaml + + +def setup_logging_configuration(configuration_file_path="logging.yaml", configuration_level=logging.INFO, + environment_key="LOG_CFG"): + + """ + Check for an existing configuration file logging.yaml, which contains the configuration for logging, and use it, if + it exists. Use a default, basic configuration, if it does not exist. + """ + + # Get environment variable based on the given key. + environment_value = os.getenv(environment_key, None) + + # If the environment value exists, use it as path. + if environment_value: + configuration_file_path = environment_value + + # If there is a configuration path with an existing file, use it. + if os.path.exists(configuration_file_path): + # Open the configuration in read and text mode, which is necessary for the yaml functionality. + with open(configuration_file_path, "rt") as configuration_file: + # Read the configuration file with the yaml function for safe load. This is necessary because a user can + # change their configuration. + configuration = yaml.safe_load(configuration_file.read()) + + # Take the configuration information out of the created dictionary. + logging.config.dictConfig(configuration) + + else: + # If a configuration file is not found, make a simple output. + logging.basicConfig(level=configuration_level) + # Use logging to inform the user about insufficient logging. + logging.warning("A configuration file was not found in the path {}. .log files will not be " + "produced. Please check your logging settings and configuration " + "file".format(configuration_file_path)) + diff --git a/pygadmin/logging.yaml b/pygadmin/logging.yaml new file mode 100644 index 0000000..2ea462d --- /dev/null +++ b/pygadmin/logging.yaml @@ -0,0 +1,41 @@ +--- +version: 1 +disable_existing_loggers: False +formatters: + simple: + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: logging.StreamHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + + info_file_handler: + class: logging.handlers.RotatingFileHandler + level: INFO + formatter: simple + filename: info.log + maxBytes: 10485760 + backupCount: 20 + encoding: utf8 + + error_file_handler: + class: logging.handlers.RotatingFileHandler + level: ERROR + formatter: simple + filename: errors.log + maxBytes: 10485760 + backupCount: 20 + encoding: utf8 + +loggers: + my_module: + level: ERROR + handlers: [console] + propagate: no + +root: + level: INFO + handlers: [console, info_file_handler, error_file_handler] \ No newline at end of file diff --git a/pygadmin/models/__init__.py b/pygadmin/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pygadmin/models/lexer.py b/pygadmin/models/lexer.py new file mode 100644 index 0000000..25d7d39 --- /dev/null +++ b/pygadmin/models/lexer.py @@ -0,0 +1,118 @@ +import logging + +from PyQt5.Qsci import QsciLexerSQL, QsciLexerCustom +from PyQt5.QtGui import QColor + +from pygadmin.configurator import global_app_configurator + + +class SQLLexer(QsciLexerSQL, QsciLexerCustom): + """ + Create a custom lexer class for style customization. The background color of the lexer, the font color, the color of + specific keywords are configurable. + """ + + def __init__(self, scintilla): + super().__init__(scintilla) + self.init_colors() + + def init_colors(self): + """ + Initialize the colors of the lexer. + """ + + self.color_parameters_dictionary = {} + + default_style = global_app_configurator.get_default_color_theme_style() + + if default_style: + self.style_name = default_style[0] + for color_description, color_string in default_style[1].items(): + self.color_parameters_dictionary[color_description] = QColor(color_string) + + if not default_style: + self.style_name = "Default" + self.color_parameters_dictionary = { + "default_color": QColor("ff000000"), + "default_paper_color": QColor("#ffffffff"), + "keyword_color": QColor("#ff00007f"), + "number_color": QColor("#ff007f7f"), + "other_keyword_color": QColor("#ff7f7f00"), + "apostrophe_color": QColor("#ff7f007f") + } + + self.save_dictionary_with_style_information() + + self.set_lexer_colors(self.color_parameters_dictionary) + + def set_lexer_colors(self, color_dictionary): + """ + Set the different colors of the lexer, which are given in a color dictionary. Check every item in the dictionary + for a potential match in the key. This procedure allows to check for the existence of the key and the further + usage after a match. Check also the color for a QColor, so only a color of the right type is used. Specific + integers are used in setColor for specifying the component for the color. + """ + + # Check every key and value pair in the dictionary with the colors. + for color_description, color_value in color_dictionary.items(): + # Check the given color for its instance/type. Proceed for a QColor. + if isinstance(color_value, QColor): + # Set the color for the default color. + if color_description == "default_color": + self.setDefaultColor(color_value) + + # Set the default paper color. + elif color_description == "default_paper_color": + self.setDefaultPaper(color_value) + # Use the function setColor() with the value 0 for using the given color for the paper for special + # words like keywords. + self.setColor(color_value, 0) + + # Set the color for keywords. + elif color_description == "keyword_color": + # The integer value 5 is used for the color of keywords. + self.setColor(color_value, 5) + + # Set the color for numbers. + elif color_description == "number_color": + # The integer value 4 is used for the color of numbers. + self.setColor(color_value, 4) + + # Set the color for other keywords, which are not a part of "normal" keywords. + elif color_description == "other_keyword_color": + # The integer value 8 is used for the color of those other keywords. + self.setColor(color_value, 8) + + # Set the color for the apostrophes and the text inside. + elif color_description == "apostrophe_color": + # The integer value 7 is used for the color of apostrophes and the text inside. + self.setColor(color_value, 7) + + # Use this else-branch, if the given color value is not a QColor. + else: + # Warn in the log about a missing QColor. + logging.warning("The given color {} for the color description/color key {} is not a QColor. Potential " + "changes in the lexer's and editor's color will not be " + "applied.".format(color_value, color_description)) + + def save_dictionary_with_style_information(self): + """ + Save the current style information for further usage with the global app configurator. + """ + + # Create an empty dictionary for the information. + style_information_dictionary = {} + + # Change every value of the dictionary with its key to a usable value for saving in a .yaml file. + for color_description, color_q_color in self.color_parameters_dictionary.items(): + # Use the name of the color as string for saving. + style_information_dictionary[color_description] = color_q_color.name() + + # Add the configuration to the app configurator. + global_app_configurator.add_style_configuration(self.style_name, style_information_dictionary) + # Add the configuration name as default configuration to all configurations. + global_app_configurator.set_single_configuration("color_theme", self.style_name) + # Save the style configuration. + global_app_configurator.save_style_configuration_data() + # Save the configuration data. + global_app_configurator.save_configuration_data() diff --git a/pygadmin/models/tablemodel.py b/pygadmin/models/tablemodel.py new file mode 100644 index 0000000..0e9ee93 --- /dev/null +++ b/pygadmin/models/tablemodel.py @@ -0,0 +1,121 @@ +from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt +from PyQt5.QtGui import QColor + + +class TableModel(QAbstractTableModel): + """ + Create a custom class to show data in a table with rows and columns based on given data in form of a list. A + subclass of QAbstractTableModel needs a reimplementation of the functions rowCount(), columnCount() and data(). For + well behaved models, headerData() is also necessary. + """ + + def __init__(self, data_list): + super().__init__() + + # Check for the correct instance of the data_list, which should be a list. + if isinstance(data_list, list): + self.data_list = data_list + + # If the given input parameter is not a list, use an empty list instead. + else: + self.data_list = [] + + # Define a list for storing tuples of row and column. These stored tuples are the changed ones. + self.change_list = [] + + def rowCount(self, parent=QModelIndex()): + """ + Count the number of rows in the given data list as row dimension of the table. + """ + + # If the length of the data list is 0, the list is empty and does not contain any data. + if len(self.data_list) == 0: + return 0 + + # Rows are represented by the elements of the data list. + row_number = len(self.data_list) - 1 + + return row_number + + def columnCount(self, parent=QModelIndex()): + """ + Count the number of columns in the given data list as column dimension of the table. + """ + + # If the length of the data list is 0, the list is empty and does not contain any data. + if len(self.data_list) == 0: + return 0 + + # The number of columns in a table is determined by the length of the elements in the data list. The first (or + # rather 0th) element of the list is chosen because this element will always exist at this point. + column_number = len(self.data_list[0]) + + return column_number + + def data(self, index, role=Qt.DisplayRole): + """ + Check every element of the data list and fill every cell of the table with data. If required, change also the + appearance of specific cells. + """ + + # Ensure a correct and valid index. + if not index.isValid(): + return None + + # Get the row number and column number out of the index. + row = index.row() + column = index.column() + + # Check the role: The display role should show the value in the cell. + if role == Qt.DisplayRole: + # Check every value for all row and column combinations. + row_list = self.data_list[row + 1] + value = row_list[column] + + # Return the value as a string for correct formatting. + return str(value) + + # Check for the background role, which describes the background of the cell. + if role == Qt.BackgroundRole: + # If the given combination of row and column is in the change list, mark the related cell. + if (row, column) in self.change_list: + # Change the background of the cell to blue. + return QColor("blue") + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """ + Return data for given part of the header like row title and column title. + """ + + # An incorrect role results in a bad output format. + if role != Qt.DisplayRole: + return None + + # A check for the necessary element as title or header data is required. + if orientation == Qt.Horizontal and section < self.columnCount(): + # The first (or rather 0th) element of the data list contains the header data or names of the columns. The + # part with section gets the necessary element in this list of titles. + return self.data_list[0][section] + + # Vertical orientation is meant for rows. Their name is determined by incrementation. + elif orientation == Qt.Vertical: + return section + 1 + + return None + + def refresh_data_list(self, new_data_list): + """ + Refresh the data in the table model with a new list of data. + """ + + if isinstance(new_data_list, list): + # Save the current number of columns before a change of data to compare with new data. + current_columns = self.columnCount() + # Save the new data list as the data list of the object. + self.data_list = new_data_list + # Change the data in the table to the new data. + self.dataChanged.emit(self.index(0, 0), self.index(self.rowCount(), self.columnCount())) + # Change the header data of the table to the new data. + self.headerDataChanged.emit(Qt.Horizontal, 0, + current_columns - 1 if current_columns > self.columnCount() + else self.columnCount()) diff --git a/pygadmin/models/treemodel.py b/pygadmin/models/treemodel.py new file mode 100644 index 0000000..99c0d73 --- /dev/null +++ b/pygadmin/models/treemodel.py @@ -0,0 +1,344 @@ +""" +Create a structure for elements in a database structure with nodes. This structure is based on a root node or +AbstractBaseNode. The structure contains at least one server node. A server node contains database nodes. A database +node contains schema nodes. A schema node contains tables nodes and views nodes. A tables node contains table nodes and +a views node contains view nodes. It is possible, that the number of existing sub nodes is 0. +""" + +import logging +import os + +import psycopg2 +from PyQt5.QtGui import QStandardItem, QIcon, QPixmap +from copy import deepcopy +from psycopg2 import sql + +import pygadmin +from pygadmin.connectionfactory import global_connection_factory + + +class AbstractBaseNode(QStandardItem): + """ + Create a class with attributes and functions as basis for all nodes used in the tree model. This class makes + inheritance for additional nodes possible. + """ + + def __init__(self, name, host, user, database, port, timeout, **kwargs): + super().__init__(name) + + # self.name declares the name of the node. It is not part of self.database_connection_parameters because it is + # not a connection parameter. + self.name = name + + # Create a dictionary for the connection parameters, e.g. to use them for the database connection. + self.database_connection_parameters = { + "host": host, + "user": user, + "port": port, + "database": database, + "timeout": timeout + } + + # Establish a database connection as private variable with the parameters of the connection. A special error + # handling is not necessary. This happens in the connection factory. If a connection with the value None or + # False ends up here, the program does not crash, it works with errors specified in the log. + self._database_connection = global_connection_factory.get_database_connection( + **self.database_connection_parameters) + + # Get the type of the node without a "Node" at the end based on the class and qualname. This is necessary to + # find icons in the next step. + self._node_type = self.__class__.__qualname__.replace("Node", "") + + # There are special icons for the server and its connection status. The other ones are corner cases without + # icons (yet?) + if self._node_type not in ["Server", "Schema", "Tables", "Views"]: + # Specify a path for icons. The path depends on the place of this file in the module. The icon is based + # on the node type. This procedure ensures os independent file checking. + node_icon_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", + "{}.svg".format(self._node_type.lower())) + + self.add_node_icon(node_icon_path) + + # AbstractBaseNode is some kind of root node, so a parent is not necessary. This attribute is used by nodes + # which inherit by this node. + self._parent = None + + def add_node_icon(self, icon_path): + """ + Add an icon to the name of the node. + """ + + # If the path exists, use the icons there. + if os.path.exists(icon_path): + # Create a QIcon. + node_icon = QIcon() + # Use the QIcon for adding a pixmap with the given path. + node_icon.addPixmap(QPixmap(icon_path)) + self.setIcon(node_icon) + + else: + logging.warning("Icons were not found for the node type {} with path in the treemodel.".format( + self._node_type, icon_path)) + + def add_child(self, child): + """ + Add a child, which is an input parameter, to a node, if this child is an instance of AbstractBaseNode. + """ + + if isinstance(child, AbstractBaseNode): + # Add a child with the function appendRow() of QStandardItem. + self.appendRow(child) + # Set the parent of the child to the current node to show the hierarchy. + child._parent = self + + else: + logging.warning("Child has a wrong object type, so appending is not possible. Please expect unexpected " + "behavior of the tree model.") + + def remove_child(self, child): + """ + Remove a child, which is an input parameter. A check for the instance is necessary, because there could be any + + """ + + if isinstance(child, AbstractBaseNode): + # Set the parent to None because a removed child does not need a connection to a parent. + child._parent = None + # Remove child with the function removeRow() of QStandardItem. + self.removeRow(child.row()) + + else: + logging.error("A child with a wrong type cannot be removed out of the tree model. The child is " + "{}".format(child)) + + def fetch_children(self, child_class, query, parameters=None): + """ + Fetch the children of a node with its class, a specified query and parameters as option. Parameters can be + required for example to use a query with the schema name to get the views and tables. + """ + + # Check for the existence of a database connection. If the database connection does not have the correct type, + # end the function. + if not isinstance(self._database_connection, psycopg2.extensions.connection): + return + + # Check for a closed database connection, because fetching children is not possible with a closed database + # connection. + if self._database_connection.closed == 1: + # Get a new connection. + self.update_database_connection() + + # Use the database connection of the node for executing the query. + with self._database_connection.cursor() as database_cursor: + # Execute the SQL query with its optional parameters + database_cursor.execute(sql.SQL(query), parameters) + + # database_cursor has a description if the query leads to a result, which is not None. + if database_cursor.description: + # Get all data in a list. + data_output = database_cursor.fetchall() + for element in data_output: + # The first (or 0th) element contains under some circumstances and queries the database "template0" + # as database. This database can lead to problems, so the next steps are for every result except + # template0. + if element[0] != "template0": + # The function deepcopy() creates a real copy and not only a reference, so the connection + # parameters can be copied and saved for further usage. They can also be modified without a + # modification of the original parameters. + current_database_parameter_configuration = deepcopy(self.database_connection_parameters) + current_database_parameter_configuration["name"] = element[0] + current_database_parameter_configuration["database"] = self.determine_child_database(element[0]) + # Create a child node based on the changed information and so, on the results of the query. + current_node = child_class(**current_database_parameter_configuration) + # Add the created node with its information as child to the node. + self.add_child(current_node) + + # This else branch is triggered for the database "template0". + else: + logging.info("Database template 0 appears for a potential connection. A connection is (" + "normally) impossible, so this database will not appear in the tree model, " + "preventing unexpected behavior and triggering logging messages.") + + def determine_child_database(self, child): + """ + Determine the database of the given child. + """ + + return self.database_connection_parameters["database"] + + def update_database_connection(self): + """ + Get a new database connection based on the database connection parameters. An update is necessary for closed + connections, which needs to update something, for example their structure. + """ + + self._database_connection = global_connection_factory.get_database_connection( + **self.database_connection_parameters) + + def close_database_connection(self): + """ + Close the current database connection of the node with the help of the global connection factory. + """ + + # Close the database connection after usage. + global_connection_factory.close_and_remove_database_connection(self._database_connection) + + def get_node_type(self): + """ + Get the type of the node, which is a protected variable. + """ + + # Get the node type in a new variable. + node_type = self._node_type + + return node_type + + +class ServerNode(AbstractBaseNode): + """ + Create a class for server nodes based on the class AbstractBaseNode. + """ + + def __init__(self, name, host, user, port, database="postgres", timeout=1000): + # Use the database postgres, because this database should definitely exist on a database server and is used as + # entry point here. + super().__init__(name, host, user, database, port, timeout) + + # Check for the right class as psycopg2 connection object and an open connection. If there is one, an icon for + # a valid connection is set and a query is used to get all children of the server node. + if isinstance(self._database_connection, psycopg2.extensions.connection) \ + and self._database_connection.closed == 0: + # Use the server icon for a valid connection. + node_icon_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", + "{}_valid.svg".format(self._node_type.lower())) + + # Get the children with a query. + self.get_children_with_query() + + else: + # Use the server icon for a invalid connection. + node_icon_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", + "{}_invalid.svg".format(self._node_type.lower())) + + self.add_node_icon(node_icon_path) + + self.close_database_connection() + + def determine_child_database(self, child): + """ + Overwrite the function determine_child_database of AbstractBaseNode. The children of a server node are the + database nodes. + """ + + return child + + def get_children_with_query(self): + """ + Get all children of the node with a database query. + """ + + # Use a query to get all children, in this case, all the database nodes. + self.fetch_children(DatabaseNode, "SELECT datname FROM pg_database ORDER BY datname ASC;") + + +class DatabaseNode(AbstractBaseNode): + """ + Create a class for database nodes based on the class AbstractBaseNode. + """ + + def __init__(self, name, host, user, database, port, timeout): + super().__init__(name, host, user, database, port, timeout) + + self.get_children_with_query() + + # Close the database connection after usage. + self.close_database_connection() + + def get_children_with_query(self): + """ + Get all children of the node with a database query. + """ + + # Use a query to get all children, in this case, all the schema nodes except pg_toast, pg_toast_temp_1 and + # pg_temp_1 as unused schemas. + self.fetch_children(SchemaNode, "SELECT schema_name FROM information_schema.schemata WHERE schema_name " + "!= 'pg_toast' AND schema_name != 'pg_toast_temp_1' AND schema_name " + "!= 'pg_temp_1' ORDER BY schema_name ASC;") + + +class SchemaNode(AbstractBaseNode): + """ + Create a class for schema nodes based on the class AbstractBaseNode. + """ + + def __init__(self, name, host, user, database, port, timeout): + super().__init__(name, host, user, database, port, timeout) + + # Create static nodes for tables node and views node, because their existence is not based on a query. They + # exist as a parent node for all table nodes or view nodes. The actual existence of tables or views is + # irrelevant. In this corner case, the model ends in this node for this schema and database and server. + self.add_child(TablesNode("Tables", name, host, user, database, port, timeout)) + self.add_child(ViewsNode("Views", name, host, user, database, port, timeout)) + + +class ViewsNode(AbstractBaseNode): + """ + Create a class for views nodes based on the class AbstractBaseNode. + """ + + def __init__(self, name, schema, host, user, database, port, timeout): + super().__init__(name, host, user, database, port, timeout) + + # Define the schema, which is necessary as query parameter for fetching the children. + self.schema = schema + + # Get children with an extra function. + self.get_children_with_query() + + def get_children_with_query(self): + """ + Get all children of the node with a database query. + """ + + # Use a query to get all children, in this case, all view nodes. Sort the nodes alphabetically. + self.fetch_children(ViewNode, "SELECT table_name FROM information_schema.views WHERE table_schema=%s ORDER BY" + " table_name ASC", + [self.schema]) + + +class ViewNode(AbstractBaseNode): + """ + Create a class for view nodes based on the class AbstractBaseNode. This node just exits. + """ + + pass + + +class TablesNode(AbstractBaseNode): + """ + Create a class for tables nodes based on the class AbstractBaseNode. + """ + + def __init__(self, name, schema, host, user, database, port, timeout): + super().__init__(name, host, user, database, port, timeout) + # Define the schema, which is necessary as query parameter for fetching the children. + self.schema = schema + + self.get_children_with_query() + + def get_children_with_query(self): + """ + Get all children of the node with a database query. + """ + + # Use a query to get all children, in this case, all view nodes. Sort the nodes alphabetically. + self.fetch_children(TableNode, "SELECT table_name FROM information_schema.tables WHERE table_schema=%s ORDER BY" + " table_name ASC", [self.schema]) + + +class TableNode(AbstractBaseNode): + """ + Create a class for table nodes based on the class AbstractBaseNode. This node just exists. + """ + + pass diff --git a/pygadmin/widgets/__init__.py b/pygadmin/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pygadmin/widgets/command_history.py b/pygadmin/widgets/command_history.py new file mode 100644 index 0000000..db4a08e --- /dev/null +++ b/pygadmin/widgets/command_history.py @@ -0,0 +1,338 @@ +import logging + +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import QDialog, QLabel, QListWidget, QGridLayout, QPushButton, QLineEdit, QMessageBox + +from pygadmin.command_history_store import global_command_history_store +from pygadmin.configurator import global_app_configurator +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class CommandHistoryDialog(QDialog): + """ + Create a dialog for showing the previous executed commands in the editor, which are saved in a history. + """ + + # Define a signal for getting the current selected command after a double click. + get_double_click_command = pyqtSignal(str) + + def __init__(self): + """ + Initialize the dialog based on the previous commands: If the command list is empty, this dialog does not need to + show the command selection UI. + """ + + super().__init__() + self.setModal(True) + + # Add the pygadmin icon as window icon. + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + + # Get the current command history list. + command_history_list = global_command_history_store.get_command_history_from_yaml_file() + + # Check the command history list for emptiness. If the list is empty, shown a UI with an information about the + # empty list. + if not command_history_list: + self.show_empty_ui() + + # In this case, the list is not empty and the previous commands can be shown properly. + else: + # Get the reversed list, so the newest command is at the top of the list. + command_history_list.reverse() + self.command_history_list = command_history_list + self.init_ui() + self.init_grid() + + def show_empty_ui(self): + """ + Show an empty user interface with only one label for the information about an empty history. + """ + + # Create a label for informing about the empty history. + self.empty_label = QLabel("There are currently no commands in your history.") + # Place the label in a grid layout. + grid_layout = QGridLayout(self) + grid_layout.addWidget(self.empty_label, 0, 0) + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + # Set a maximum size and show the widget. + self.setMaximumSize(200, 60) + self.setWindowTitle("Command History Information") + self.show() + + def init_ui(self): + """ + Initialize the main user interface. + """ + + # Create a list widget for showing the main information about a previous SQL command. + self.history_list_widget = QListWidget() + # Define labels for showing the information about a previous command. + self.command_label = QLabel("SQL Command") + # Set the command text as selectable. + self.command_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + # Define a label for the date and the time. + self.date_time_label = QLabel("Date and Time") + # Define a label for the connection identifier. + self.connection_identifier_label = QLabel("Connection Identifier") + + # Add a line edit for changing the command limit. + self.command_limit_line_edit = QLineEdit() + # Load the current limit in the line edit. + self.load_current_command_limit_in_line_edit() + + # Create a button for saving a command limit. + self.save_command_limit_button = QPushButton("Save Command Limit") + # Connect the button with the function for checking and saving the command limit with user interaction. + self.save_command_limit_button.clicked.connect(self.check_and_save_command_limit) + + # Create a button for deleting the current selected command. + self.delete_selected_command_button = QPushButton("Delete Selected Command") + # Set the button to disabled, because at the beginning, there is no selected item. + self.delete_selected_command_button.setEnabled(False) + # Connect the button with the function for deleting the selected command. + self.delete_selected_command_button.clicked.connect(self.delete_selected_command) + + # Create a button for deleting the full history. + self.delete_full_history_button = QPushButton("Delete Full History") + # Connect the button with the function for deleting the history after asking the user, if they really want to + # delete the full history. + self.delete_full_history_button.clicked.connect(self.delete_full_command_history_after_user_question) + + # Initialize the command history. + self.init_command_history() + + # Connect the signal for changing the selection in the list widget with the method for showing the information + # about a previous command in the pre-defined QLabels. + self.history_list_widget.itemSelectionChanged.connect(self.show_command_information_in_labels) + + self.history_list_widget.doubleClicked.connect(self.use_current_command_in_editor) + + self.setMaximumSize(1280, 720) + self.setWindowTitle("Command History Information") + self.show() + + def init_grid(self): + """ + Initialize a grid layout as layout of the dialog. + """ + + grid_layout = QGridLayout(self) + + # Set the list widget as one big widget on the left of the application. + grid_layout.addWidget(self.history_list_widget, 0, 0, 5, 1) + # Place the information labels on the right. + grid_layout.addWidget(self.command_label, 0, 1) + grid_layout.addWidget(self.date_time_label, 1, 1) + grid_layout.addWidget(self.connection_identifier_label, 2, 1) + # Add the line edit and the buttons for saving and deleting under the list widget and the components with + # information about the command. + grid_layout.addWidget(self.command_limit_line_edit, 6, 0) + grid_layout.addWidget(self.save_command_limit_button, 6, 1) + grid_layout.addWidget(self.delete_selected_command_button, 7, 1) + grid_layout.addWidget(self.delete_full_history_button, 8, 1) + + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def init_command_history(self): + """ + Initialize the command history in the list widget for the history. Add every previous command to the + """ + + # Define a counter for inserting items to the history list widget. + command_history_number_count = 0 + + # Add every previous command to the list widget. + for command_dictionary in self.command_history_list: + # Create a unique command identifier, based on the command itself and the used time. + command_identifier = "{}\n{}".format(command_dictionary["Command"], command_dictionary["Time"]) + # Insert the identifier with the counter as a place in the list widget. + self.history_list_widget.insertItem(command_history_number_count, command_identifier) + # Increment the number count for the next item. + command_history_number_count += 1 + + def get_command_dictionary_of_current_selected_identifier(self): + """ + Get the command dictionary for the current selected command identifier. + """ + + # Check for selected items. + if self.history_list_widget.selectedItems(): + # Get the text of the selected item. + selected_item_text = self.history_list_widget.selectedItems()[0].text() + + # Use the command history list for finding the match. + for command_dictionary in self.command_history_list: + # Define a command identifier for finding the correct dictionary for showing the information. + command_identifier = "{}\n{}".format(command_dictionary["Command"], command_dictionary["Time"]) + + # Check for a match. + if command_identifier == selected_item_text: + # Return the command dictionary with a match. + return command_dictionary + + def show_command_information_in_labels(self): + """ + Show the information about a selected command in the QLabels. + """ + + command_dictionary = self.get_command_dictionary_of_current_selected_identifier() + + if command_dictionary is not None: + # Change the text of the labels and the data of the table to the items of the dictionary. + self.command_label.setText(command_dictionary["Command"]) + self.date_time_label.setText(command_dictionary["Time"]) + self.connection_identifier_label.setText(command_dictionary["Identifier"]) + + # Enable the button for deleting the selected command. + self.delete_selected_command_button.setEnabled(True) + + def load_current_command_limit_in_line_edit(self): + """ + Load the current command limit out of the app configurator into the line edit. + """ + + # Get the current command limit out of the configurator. + command_limit = global_app_configurator.get_single_configuration("command_limit") + # Set the current command limit as text in the command limit line edit. The cast to string is used for the + # command limit "None". + self.command_limit_line_edit.setText(str(command_limit)) + + def save_current_command_limit(self): + """ + Save the current command limit of the line edit in the global app configurator. + """ + + # Get the current text of the command line edit. + command_limit_line_edit_text = self.command_limit_line_edit.text() + + # If the text is None, then the new command limit is None. + if command_limit_line_edit_text == "None": + new_command_limit = None + + # If the text is not None, it is a valid string, which can be casted to an integer value. + else: + new_command_limit = int(command_limit_line_edit_text) + + # Save the new command limit. + global_app_configurator.set_single_configuration("command_limit", new_command_limit) + # Save the configuration data in a persistent yaml file. + global_app_configurator.save_configuration_data() + + def check_valid_command_limit(self): + """ + Check for a valid command limit in the line edit. + """ + + # Get the current user input for the command limit out of the command limit line edit. + command_limit = self.command_limit_line_edit.text() + + # If the input for the command limit is None as text, return True, because None is a valid text for showing a + # non-existing limit. + if command_limit == "None": + return True + + # Try to cast the command limit to an integer. This works in case of a valid string with a possible cast. + try: + # Get the command limit as integer. + command_limit = int(command_limit) + + # The command limit is valid, if it is larger than 0, so True is returned in this case. As a consequence, + # False is returned in the invalid cases. + return command_limit > 0 + + # Return False for a value error, because in this case, the command limit is invalid. + except ValueError: + return False + + def check_and_save_command_limit(self): + """ + Check for a valid command limit in the command limit line edit. If the limit is invalid, show a message to the + user about the invalidity. If the limit is valid, save the limit, inform the command history store and the user + about the success. + """ + + if self.check_valid_command_limit() is False: + QMessageBox.critical(self, "Command Limit Error", "The command limit you entered is invalid. Please enter" + "an integer number larger than 0 or None, if you do " + "not wish to use a limit.") + + return + + # Save the current command limit in the global app configurator. + self.save_current_command_limit() + # Inform the global command history store about a new limit and adjust the saved history to the new command + # limit. + global_command_history_store.adjust_saved_history_to_new_command_limit() + # Save the current list in the yaml file. + global_command_history_store.commit_current_list_to_yaml() + + QMessageBox.information(self, "Command Limit Saved", "The new command limit was saved successfully. Potential " + "changes in the command history dialog may apply after " + "a reopening of the dialog.") + + def delete_selected_command(self): + """ + Get the command dictionary of the current selected command identifier and delete it in the list widget and in + the global command history. + """ + + # Get the command dictionary of the current selected identifier. + command_dictionary = self.get_command_dictionary_of_current_selected_identifier() + # Delete the selected item in the list widget with the method takeItem. The current row is the current selected + # item. An additional or manual deletion process is not necessary, because this is the world of Python with a + # functional garbage collection. + self.history_list_widget.takeItem(self.history_list_widget.currentRow()) + # Delete the command in the history. + global_command_history_store.delete_command_from_history(command_dictionary) + # Clear the selection in the list widget. + self.history_list_widget.selectionModel().clear() + # Deactivate the button for deleting the selected command, because there is no selected command. + self.delete_selected_command_button.setEnabled(False) + + def delete_full_command_history(self): + """ + Delete the full command history and close the dialog after the deletion. + """ + + global_command_history_store.delete_all_commands_from_history() + self.close() + + def delete_full_command_history_after_user_question(self): + """ + Delete the full command history after a confirmation by the user. + """ + + delete_full_command_history = QMessageBox.question(self, "Delete Full Command History", "Do you really want to" + "delete the full " + "command history?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if delete_full_command_history == QMessageBox.Yes: + self.delete_full_command_history() + + def use_current_command_in_editor(self): + """ + Get the current selected command after a double click. Emit a signal, so the command can be used be the editor + in the main window. + """ + + # Get the dictionary of the selected command. + selected_command_dictionary = self.get_command_dictionary_of_current_selected_identifier() + + # Try to get the selected command in the dictionary with its key. + try: + selected_command = selected_command_dictionary["Command"] + # Emit the signal with the selected command. + self.get_double_click_command.emit(selected_command) + # Close the dialog, because browsing the history is no longer necessary. + self.close() + + except KeyError: + logging.critical("The selected dictionary for commands in the command history does not contain the key" + "command: {}".format(selected_command_dictionary)) + diff --git a/pygadmin/widgets/configuration_settings.py b/pygadmin/widgets/configuration_settings.py new file mode 100644 index 0000000..74aaff4 --- /dev/null +++ b/pygadmin/widgets/configuration_settings.py @@ -0,0 +1,251 @@ +from PyQt5.QtWidgets import QDialog, QLabel, QGridLayout, QCheckBox, QPushButton, QMessageBox, QLineEdit, QFileDialog + +from pygadmin.configurator import global_app_configurator +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class ConfigurationSettingsDialog(QDialog): + """ + Use a dialog for configure the setting in app_configuration.yaml with a GUI and the help of the global app + configurator. + """ + + def __init__(self): + """ + Make sub functions for initializing the widget, separated by the parts user interface and grid layout. + """ + + super().__init__() + self.setModal(True) + # Add the pygadmin icon as window icon. + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + self.init_ui() + self.init_grid() + + def init_ui(self): + """ + Design the user interface and its components. + """ + + # Ensure that all current data in the following dictionary has been loaded. + global_app_configurator.load_configuration_data() + # Get all current configurations in a dictionary. + configurations_dictionary = global_app_configurator.get_all_current_configurations() + # Define a class dictionary for the configuration details with its GUI components. + self.configuration_dictionary = {} + + # Define a label with the purpose of the widget as description. + self.settings_description_label = QLabel("Edit the current configuration settings of pygadmin") + + # Predefined a path for the pg_dump executable. + pg_dump_path = None + + # Check all descriptions and values of a configuration in the dictionary with the configurations. + for configuration_description, current_configuration_value in configurations_dictionary.items(): + # Make the configuration description more readable for the user. + configuration_description = configuration_description.replace("_", " ") + if isinstance(current_configuration_value, bool): + # Create a label with a description for every configuration. + configuration_description_label = QLabel(configuration_description) + # Create a checkbox for every configuration. + configuration_checkbox = QCheckBox("Active") + + # If the current configuration is True, the configuration is active. + if current_configuration_value is True: + # Set the checkbox for an active configuration as checked. + configuration_checkbox.setChecked(True) + + # Save all the created components in the dictionary. + self.configuration_dictionary[configuration_description] = [configuration_description_label, + configuration_checkbox] + # Check the description for a pg dump path. + if configuration_description == "pg_dump_path": + # If a path exists, use the pre defined variable. + pg_dump_path = current_configuration_value + + # Create a label for the path. + self.pg_dump_path_label = QLabel("pg_dump path") + # Create a line edit for the path. The path can be edited manually. + self.pg_dump_line_edit = QLineEdit() + # Save the current information about the description and the GUI elements in a dictionary. + self.configuration_dictionary["pg_dump_path"] = [self.pg_dump_path_label, self.pg_dump_line_edit] + + # Check the pg_dump path. If there was a previous path, set the path as text in the line edit. + if pg_dump_path is not None: + self.pg_dump_line_edit.setText(pg_dump_path) + + # Create a button for choosing a file. + self.pg_dump_button = QPushButton("...") + # Connect the button with the function for choosing a path/file for the pg_dump executable. + self.pg_dump_button.clicked.connect(self.choose_pg_dump_path) + + # Create a button for saving the current configuration and closing after saving. + self.save_button = QPushButton("Save") + self.save_button.clicked.connect(self.save_current_configuration_and_close) + # Create a button for applying the changes without closing. + self.apply_button = QPushButton("Apply") + self.apply_button.clicked.connect(self.save_current_configuration) + # Create a button for canceling the input. + self.cancel_button = QPushButton("Cancel") + # Set a maximum size for the cancel button, so the button is not too prominent. + self.cancel_button.setMaximumSize(90, 30) + self.cancel_button.clicked.connect(self.close_with_check_for_unsaved_configuration) + + self.setWindowTitle("Settings") + # Define a maximum size for the widget and show the widget with the maximum size. + self.setMaximumSize(720, 160) + self.showMaximized() + self.show() + + def init_grid(self): + """ + Set a grid layout to the widget and place all its components. + """ + + # Define the layout. + grid_layout = QGridLayout(self) + + # Add the description label. + grid_layout.addWidget(self.settings_description_label, 0, 0) + + # Create an incrementer for placing the components in the dictionary at the right place. + grid_incrementer = 1 + + # Iterate over all values of the dictionary with all configurations. + for configuration_gui_elements in self.configuration_dictionary.values(): + # Place the description on the left. + grid_layout.addWidget(configuration_gui_elements[0], grid_incrementer, 0) + # Place the checkbox on the right. + grid_layout.addWidget(configuration_gui_elements[1], grid_incrementer, 2, 1, 2) + grid_incrementer += 1 + + # Place the components for the pg dump information. + grid_layout.addWidget(self.pg_dump_path_label, grid_incrementer, 0) + grid_layout.addWidget(self.pg_dump_line_edit, grid_incrementer, 1) + grid_layout.addWidget(self.pg_dump_button, grid_incrementer, 2) + grid_incrementer += 1 + + # Place the buttons below the description and the checkbox. + grid_layout.addWidget(self.cancel_button, grid_incrementer, 0) + grid_layout.addWidget(self.save_button, grid_incrementer, 1) + grid_layout.addWidget(self.apply_button, grid_incrementer, 2) + + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def save_current_configuration(self): + """ + Save the current configuration data based on the status of the checkboxes. + """ + + # Check every configuration and every checkbox. + for configuration_description, configuration_elements in self.configuration_dictionary.items(): + # Replace the more readable " " with the "_" for saving. + configuration_description = configuration_description.replace(" ", "_") + user_element = configuration_elements[1] + + # Check for a check box as GUI element. + if isinstance(user_element, QCheckBox): + # Set the configuration based on the description and the current status of the checkbox. If the box is + # checked, submit a True, if not, a False. + global_app_configurator.set_single_configuration(configuration_description, user_element.isChecked()) + + # Check for a line edit as GUI element. + elif isinstance(user_element, QLineEdit): + global_app_configurator.set_single_configuration(configuration_description, user_element.text()) + + # Save the current configuration data in the file. + global_app_configurator.save_configuration_data() + + # Return True for a success. + return True + + def save_current_configuration_and_close(self): + """ + Use the function for saving the current configuration data and close the dialog after saving. + """ + + # Save the current configuration with the designated function. + self.save_current_configuration() + # Close the dialog. + self.close() + + def check_for_unsaved_configuration(self): + """ + Check for a potential unsaved configuration configuration by the saved configuration of the global app + configurator and the status of the checkboxes. If there is a match as unsaved connection, return True. + """ + + # Ensure the load of all current data for the following method call. + global_app_configurator.load_configuration_data() + # Get the previous configuration. + previous_configuration = global_app_configurator.get_all_current_configurations() + + # Check every element in the previous configuration. + for configuration_key, configuration_value in previous_configuration.items(): + # Use the more readable version. + configuration_key = configuration_key.replace("_", " ") + # Check every in the class dictionary for the configurations with the description and the GUI elements. + for configuration_description, configuration_elements in self.configuration_dictionary.items(): + # Define the second element of the list of configuration as user element - because it is used for user + # input. + user_element = configuration_elements[1] + # Check for a checkbox as GUI element. + if isinstance(user_element, QCheckBox): + # If the two configurations keys/descriptions are identical, check for the configuration value. + # Check for the boolean of the previous configuration. Check also for the status of the checkbox. If + # the booleans are identical, nothing has changed. If they are not, there is an unsaved + # configuration. + if configuration_key == configuration_description \ + and configuration_value != user_element.isChecked(): + # Return True for an unsaved configuration. + return True + + # Check for a QLineEdit as GUI element. + elif isinstance(user_element, QLineEdit): + # If the configuration key matches with the description and the value is different to the previous + # one, proceed. + if configuration_key == configuration_description \ + and configuration_value != user_element.text(): + # Return True for an unsaved configuration. + return True + + # If this point is reached, there is no match and as a result, there are not any unsaved configurations. + return False + + def close_with_check_for_unsaved_configuration(self): + """ + Close the current dialog with a check for unsaved configurations. If there is an unsaved configuration, ask the + user for the following procedure. + """ + + # Check for an unsaved configuration. + if self.check_for_unsaved_configuration(): + # If there is an unsaved configuration, ask the user. + proceed_with_unsaved_changes = QMessageBox.question(self, "Proceed with unsaved changes?", + "Do you want to close the dialog with unsaved changes? " + "These changes will be deleted without saving.") + + # Check for the user's answer. + if proceed_with_unsaved_changes == QMessageBox.No: + # If the user does not want to proceed and delete unsaved changes, end the function with a return. + return + + # Close the dialog for all cases without unsaved changes or with user consent. + self.close() + + def choose_pg_dump_path(self): + """ + Choose a path for the pg_dump executable and use it as text in the related QLineEdit. + """ + + # Get a file name and type with a QFileDialog. + choose_dialog_file_and_type = QFileDialog.getOpenFileName(self, "Use pg_dump executable") + # The file name is the first part of the resulting tuple. + file_name = choose_dialog_file_and_type[0] + + # If the file name is not an empty string, proceed. + if file_name != "": + # Set the file name with its path as text of the QLineEdit. + self.pg_dump_line_edit.setText(file_name) diff --git a/pygadmin/widgets/connection_dialog.py b/pygadmin/widgets/connection_dialog.py new file mode 100644 index 0000000..3758f7a --- /dev/null +++ b/pygadmin/widgets/connection_dialog.py @@ -0,0 +1,1056 @@ +import logging +import os +import keyring +import re + +from PyQt5.QtGui import QKeySequence, QPixmap +from PyQt5.QtWidgets import QDialog, QLabel, QLineEdit, QGridLayout, QPushButton, QMessageBox, QCheckBox, QShortcut, \ + QListWidget +from PyQt5.QtCore import pyqtSignal + +import pygadmin +from pygadmin.connectionstore import global_connection_store +from pygadmin.connectionfactory import global_connection_factory +from pygadmin.configurator import global_app_configurator +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class ConnectionDialogWidget(QDialog): + """ + Create a class for entering database parameter with a check, if they are valid or if they already exist. This is a + master class, which can be used as template for different connection dialogs. + """ + + # Create a signal for emitting modified connection parameters. + get_modified_connection_parameters = pyqtSignal(tuple) + new_timeout_for_connections = pyqtSignal(bool) + + def __init__(self): + """ + Make sub functions for initializing the widget, separated by the parts user interface, grid layout and SQL + lexer. + """ + + super().__init__() + # While this widget is active, all the other components are frozen and cannot be used until this widget is + # closed again. + self.setModal(True) + self.service_name = "Pygadmin" + # Add the pygadmin icon as window icon. + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + + self.init_ui() + self.init_grid() + + def init_ui(self): + """ + Design the user interface and its components with separated functions for every part of the interface. The order + of initialization is related to the interaction of components with each other. + """ + + # Initialize the line edits. + self.init_line_edit_ui() + + # Initialize the buttons. + self.init_button_ui() + + # Initialize the label with an unknown and not tested connection. + self.init_connection_status_label() + + # Initialize the list widget with all database connections. + self.init_list_widget_ui() + + # Initialize the check boxes. + self.init_checkbox_ui() + + # Adjust the size of the dialog. + self.setMaximumSize(1260, 320) + self.showMaximized() + + self.setWindowTitle("Modify Database Connections") + + self.show() + + def init_line_edit_ui(self): + # Make a list for storing the QLabels for every connection parameter. This is necessary for using the QLabel in + # the layout setting. + self.connection_parameter_label_list = [] + # Make a dictionary for storing the QLineEdits for every connection parameter with its name as key. This is + # necessary for using the QLineEdit in the layout setting. The dictionary also contains the names of the + # necessary parameters. None is chosen as value, because in the next step, the parameters will get the + # QLineEdit as value. + self.connection_parameter_edit_dictionary = {"Host": None, + "Username": None, + "Password": None, + "Database": None, + "Port": None + } + + # Create labels for the information about the current connection status and a check for the current connection. + # This part must be initialized at this part, because the QLineEdits use a signal connected to these labels. + self.test_given_connection_label = QLabel("Current Connection Status") + # Create a label for the connection status. + self.test_given_connection_status_label = QLabel() + # Create a label for graphical information about the connection status. + self.test_given_connection_status_pixmap_label = QLabel() + + for connection_parameter in self.connection_parameter_edit_dictionary.keys(): + # Make a label for a parameter. + self.connection_parameter_label = QLabel(connection_parameter) + # Store the parameter in the list. + self.connection_parameter_label_list.append(self.connection_parameter_label) + # Make a line edit for a parameter for further usage. + self.connection_parameter_edit = QLineEdit() + # If the text is changed, the parameters are not the checked parameters, if they were checked. A change in + # the connection parameters leads to an unchecked connection, so the labels for the connection status are + # changed to a state of the unknown. + self.connection_parameter_edit.textChanged.connect(self.init_connection_status_label) + # Store the parameter in the dictionary for further usage. + self.connection_parameter_edit_dictionary[connection_parameter] = self.connection_parameter_edit + + # Check for the parameter for the password. + if connection_parameter == "Password": + # Set the QLineEdit to the password mode, so a password is not visible while writing it in the line edit + # field. + self.connection_parameter_edit.setEchoMode(QLineEdit.Password) + + self.change_global_timeout_label = QLabel("Set the global timeout for a query") + self.timeout_label = QLabel("Timeout [ms]") + self.timeout_line_edit = QLineEdit() + self.get_current_timeout() + + def init_list_widget_ui(self): + """ + Create the user interface for the list widget, which contains all database connections. These can be selected, + so modifying them is possible. + """ + + self.choose_connection_label = QLabel("Please choose the database connection you want to change") + + # Make a checkbox for the order of connections in the QListWidget + self.connection_order_checkbox = QCheckBox("Show latest connection first") + # Define the name of the configuration. + self.connection_order_configuration_name = "show_latest_connection_in_tree_first" + # Load the current configuration. + self.init_connection_order_checkbox_configuration() + # Connect the change of the checkbox to a function, which changes the widgets order. + self.connection_order_checkbox.stateChanged.connect(self.set_connection_to_order_in_list_widget) + + # Create a list widget as a container for all current identifier and connections. + self.connection_parameters_list_widget = QListWidget() + # Populate the list widget with all current connections. + self.get_current_connections_to_list_widget() + + self.connection_parameters_list_widget.itemSelectionChanged.connect(self.insert_parameters_in_edit_fields) + + # Initialize the variables for selected connections. + self.selected_connection_parameters_dictionary = {} + self.selected_connection_identifier = None + + def init_button_ui(self): + """ + Create all buttons with their connected functions and set some of them disabled. + """ + + # Create a button for testing the connection. + self.test_given_connection_button = QPushButton("Test Connection") + self.test_given_connection_button.clicked.connect(self.test_current_database_connection) + + # Create a button for committing the connection parameters given by the user. + self.new_connection_button = QPushButton("Create New Connection") + self.new_connection_button.clicked.connect(self.set_given_connection_parameters_wrapper) + # Create a shortcut for committing the connection parameters given by the user with the usage of return. + self.new_connection_shortcut = QShortcut(QKeySequence("Return"), self) + self.new_connection_shortcut.activated.connect(self.set_given_connection_parameters_wrapper) + + self.save_connection_button = QPushButton("Save Selected Connection") + self.save_connection_button.clicked.connect(self.save_connection_changes) + self.save_connection_button.setEnabled(False) + + # Create a button to delete a connection. + self.delete_current_connection_button = QPushButton("Delete") + self.delete_current_connection_button.clicked.connect(self.delete_selected_connection_after_user_question) + # Disable button, because at the start of the widget, there will not be a selected connection. This button only + # makes sense with a selected connection. + self.delete_current_connection_button.setEnabled(False) + + # Create a button for closing the widget. + self.cancel_parameter_input_button = QPushButton("Close") + self.cancel_parameter_input_button.clicked.connect(self.close) + + # Set a second function for a clicked cancel button, so the configuration is saved. + self.cancel_parameter_input_button.clicked.connect(global_app_configurator.save_configuration_data) + + self.set_timeout_button = QPushButton("Set Timeout") + self.set_timeout_button.clicked.connect(self.set_current_timeout) + + def init_checkbox_ui(self): + """ + Create the different checkboxes in the application. + """ + + # Create a checkbox for the usage of the standard postgres port. + self.use_postgres_port_checkbox = QCheckBox("Use standard postgres port") + # Set the checkbox checked as default, so the standard postgres port is set. + self.use_postgres_port_checkbox.setChecked(True) + # Ensure the correct settings for a checked checkbox. + self.set_port_edit_field_to_checkbox() + # Define the variable for a potential overwrite, which will happen, if the checkbox is chosen. A chosen checkbox + # is the default, so this needs to be set at the beginning. + self.port_to_overwrite = "" + # Connect the state change of the checkbox to a function which handle a(n un)checked checkbox. + self.use_postgres_port_checkbox.stateChanged.connect(self.set_port_edit_field_to_checkbox) + + # Create a checkbox for the usage of the standard postgres database. + self.use_postgres_database_checkbox = QCheckBox("Use standard postgres database") + # Set the checkbox checked as default, so the standard database is set. + self.use_postgres_database_checkbox.setChecked(True) + # Activate the settings for a checked checkbox. + self.set_database_edit_field_to_checkbox() + # Define a variable for potential overwrite, which is overwritten, if the checkbox is checked. + self.database_to_overwrite = "" + # Connect the state change to a function for handling a change in the checked status of the checkbox. + self.use_postgres_database_checkbox.stateChanged.connect(self.set_database_edit_field_to_checkbox) + + # Create a checkbox for usage in specific cases. + self.open_at_start_checkbox = QCheckBox("Open always a connection dialog at start") + + # Define a configuration option for opening the connection dialog at the start of the application. + self.open_at_start_configuration = "open_connection_dialog_at_start" + + # Set the default option of the box as checked, so without any changes by the user, + self.open_at_start_checkbox.setChecked(True) + + # Connect the (un)checked change state of the checkbox to a function for setting the configuration. + self.open_at_start_checkbox.stateChanged.connect(self.save_open_at_start_checkbox_configuration) + + def init_grid(self): + """ + Set a grid layout to the widget and place all its components. + """ + + # Set the layout to a grid layout. + grid_layout = QGridLayout(self) + + # Use the length of the list of parameters, because it contains all labels and so, every label is placed. + for parameter_number in range(len(self.connection_parameter_label_list)): + # The last parameter needs special handling caused by the placing of the checkbox for database. + if parameter_number != 4: + # Place every QLabel on the grid layout with an of by one, because in sub class, there can be a widget. + grid_layout.addWidget(self.connection_parameter_label_list[parameter_number], parameter_number + 1, 2) + + # This case happens for the label of the port. + else: + # The label is placed further down, so the checkbox for the default database is placed accurate. + grid_layout.addWidget(self.connection_parameter_label_list[parameter_number], parameter_number + 2, 2) + + # Use an incrementer for the next for loop. + connection_parameter_edit_incrementer = 1 + + # Use the connection parameters key and their related values. The values are QLineEdits and they are placed. + for connection_parameter, connection_parameter_edit in self.connection_parameter_edit_dictionary.items(): + # Place every QLineEdit on the grid layout. + grid_layout.addWidget(connection_parameter_edit, connection_parameter_edit_incrementer, 3) + # Increment the incrementer, so the following QLineEdit is placed below the one before. + connection_parameter_edit_incrementer += 1 + + # Check for the special database case. + if connection_parameter == "Database": + # Place the checkbox under the QLineEdit for the database. + grid_layout.addWidget(self.use_postgres_database_checkbox, connection_parameter_edit_incrementer, 3) + connection_parameter_edit_incrementer += 1 + + # The port as the parameter which belongs to this checkbox is the last one in the list for the user, so this + # checkbox is placed below the QLineEdit for the port. + grid_layout.addWidget(self.use_postgres_port_checkbox, connection_parameter_edit_incrementer + 1, 3) + + # Place the button for canceling the input. + grid_layout.addWidget(self.cancel_parameter_input_button, connection_parameter_edit_incrementer + 4, 4) + + # Place the button for committing the connection parameters below the QLineEdits. + grid_layout.addWidget(self.new_connection_button, connection_parameter_edit_incrementer + 4, 3) + + grid_layout.addWidget(self.change_global_timeout_label, 1, 4) + grid_layout.addWidget(self.timeout_label, 3, 4) + grid_layout.addWidget(self.timeout_line_edit, 4, 4) + grid_layout.addWidget(self.set_timeout_button, 5, 4) + + # Place the components for a connection check. The first label and the button are two grids wide, so the + # information in the QLabels with the text and the pixmap can be placed in two grid, which look like one. + grid_layout.addWidget(self.test_given_connection_label, 3, 5, 1, 2) + grid_layout.addWidget(self.test_given_connection_status_pixmap_label, 4, 6) + grid_layout.addWidget(self.test_given_connection_status_label, 4, 5) + grid_layout.addWidget(self.test_given_connection_button, 5, 5, 1, 2) + + # Place the relevant parts for the list widget and the related buttons. + grid_layout.addWidget(self.choose_connection_label, 1, 0) + grid_layout.addWidget(self.connection_parameters_list_widget, 2, 0, 5, 1) + grid_layout.addWidget(self.connection_order_checkbox, connection_parameter_edit_incrementer + 1, 0) + grid_layout.addWidget(self.delete_current_connection_button, connection_parameter_edit_incrementer + 4, 2) + grid_layout.addWidget(self.save_connection_button, connection_parameter_edit_incrementer + 4, 0) + + # Place checkbox with in relation to the connection incrementer. + grid_layout.addWidget(self.open_at_start_checkbox, connection_parameter_edit_incrementer + 3, 3, 1, 2) + + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def init_connection_status_label(self): + """ + Design the connection status label content for the first time. They show, if a connection is tested or not. If + a connection is untested, the labels say so. + """ + + # Set the text label to an untested connection. + self.test_given_connection_status_label.setText("Not tested yet") + # Get the relevant connection icon. + server_status_pixmap_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", "server_pending.svg") + # Add pixmap icon with the function to the QLabel. + self.add_server_pixmap(server_status_pixmap_path) + + # Define the existence of empty line edits as False. + existence_of_empty_line_edits = False + + # Check the line edit fields in the dictionary, if they are really not empty. + for line_edit_key, line_edit_field in self.connection_parameter_edit_dictionary.items(): + # Check for an empty field. An empty field is irrelevant for the parameter password, because a database + # connection can exists without a password. + if line_edit_field.text() == "" and line_edit_key != "Password": + # Change the existence of empty fields to True for further usage. + existence_of_empty_line_edits = True + + # Check, if there are empty line edit fields. + if existence_of_empty_line_edits is True: + # Set the button for testing connections disabled, so a connection with empty parameters can not be tested, + # because the test would lead to an invalid connection. + self.test_given_connection_button.setEnabled(False) + + # This else branch is for edit fields, which are not empty- + else: + # Enable the button for testing a connection, + self.test_given_connection_button.setEnabled(True) + + def add_server_pixmap(self, pixmap_path): + """ + Add the pixmap at the given path, if existing, to the QLabel for a pixmap. + """ + + # If the path exists, use the pixmap there. + if os.path.exists(pixmap_path): + # Create a QPixmap. + server_icon = QPixmap(pixmap_path) + # Set the pixmap to the related label. + self.test_given_connection_status_pixmap_label.setPixmap(server_icon) + + else: + logging.warning("Icons were not found for the server status with path {}.".format(pixmap_path)) + + def set_port_edit_field_to_checkbox(self): + """ + Get the current status of the checkbox for the usage of the standard postgres port. If it is checked, save the + current port and then use the standard postgres port 5432. If not, set the port to the previous port. + """ + + # Check for a checked checkbox. + if self.use_postgres_port_checkbox.isChecked() is True: + # Save the current port, because this field could contain a value. + self.port_to_overwrite = self.connection_parameter_edit_dictionary["Port"].text() + # Set the port to the postgres standard port 5432. + self.connection_parameter_edit_dictionary["Port"].setText("5432") + + # Use this branch for an unchecked box. + else: + # Set the text to the previous database. + self.connection_parameter_edit_dictionary["Port"].setText(self.port_to_overwrite) + + def set_database_edit_field_to_checkbox(self): + """ + Get the current status of the checkbox for the usage of the standard postgres database. If it is checked, save + the current database and then use the standard postgres database. If not, set the database to the previous + database. + """ + + # Check for a checked checkbox. + if self.use_postgres_database_checkbox.isChecked() is True: + # Save the current database. + self.database_to_overwrite = self.connection_parameter_edit_dictionary["Database"].text() + # Set the database to the standard postgres database. + self.connection_parameter_edit_dictionary["Database"].setText("postgres") + + # Use this branch for an unchecked box. + else: + # Set the text to the previous database. + self.connection_parameter_edit_dictionary["Database"].setText(self.database_to_overwrite) + + def set_given_connection_parameters_wrapper(self): + """ + Use a wrapper function, which is used by the button and shortcut for committing the connection details. This + wrapper function can be overwritten by child classes instead of causing an overflow by an overwritten signal, + which could happen and is extremely unpleasant. + """ + + self.commit_new_parameters_to_yaml() + + global_app_configurator.save_configuration_data() + + def get_given_connection_parameters(self): + """ + Check the current values in the QLineEdit and if they are valid, return these database connection parameters, so + a function for committing the data can be used. + """ + + # Create a connection identifier with the stored values in the dictionary for further usage like saving in the + # password manager or communication to the user for an error case or committing success. + connection_identifier = "{}@{}:{}/{}".format(self.connection_parameter_edit_dictionary["Username"].text(), + self.connection_parameter_edit_dictionary["Host"].text(), + self.connection_parameter_edit_dictionary["Port"].text(), + self.connection_parameter_edit_dictionary["Database"].text()) + + # Define one log message and recycle it later for every error in this function. + commit_error_message_log = "The following connection parameters were not saved after the try to commit them, " \ + "because" + + # Get the result for an empty parameter in the edit fields. + empty_parameter_result = self.check_for_empty_parameter_edit_fields() + + # If there are empty parameters, the empty parameter result is not False. Normally, it is a list with a boolean + # for identifying the kind of error and information for showing to the user. + if empty_parameter_result is not False: + # Prepare a warning message for the log. + empty_field_logging_warning = "{} one (or more than one) field was empty: {}".format( + commit_error_message_log, connection_identifier) + + # If the first element of the list is True, essential parameters for a database connection are empty. + if empty_parameter_result[0] is True: + # Show the warning to the user in a message box. + QMessageBox.critical(self, empty_parameter_result[1], empty_parameter_result[2]) + logging.warning(empty_field_logging_warning) + + return + + # If the first element of the list is None, a question to the user is necessary for the following procedure. + elif empty_parameter_result[0] is None: + # Ask the user for proceeding without a password. + proceed_without_password = QMessageBox.question(self, empty_parameter_result[1], + empty_parameter_result[2]) + + # If the answer is no, then the user does not want to proceed without an empty password and gets the + # chance to enter one. + if proceed_without_password == QMessageBox.No: + logging.warning(empty_field_logging_warning) + + return + + # Check for a valid port. + if self.check_for_valid_port() is False: + # Create a title and a description for a potential error, which can be used in two cases. + port_input_error_title = "Invalid input for port" + port_input_error_description = "The given port number is invalid. Please enter an integer number " \ + "between 0 and 65535 for your port." + + QMessageBox.critical(self, port_input_error_title, port_input_error_description) + + # Create a warning in the log, that the current connection cannot be saved with the given identifier. + logging.warning("{} the port is invalid: {}".format(commit_error_message_log, connection_identifier)) + + return + + # Check for a changed password. If the password changed, this value is True. + changed_password = self.check_for_changed_password(connection_identifier) + + # If there is a changed password, set it. + if changed_password is True: + # Try to save the password in the password manager. + if self.set_password_with_its_identifier(connection_identifier) is False: + logging.warning("{} an error occurred while saving the password: {}".format(commit_error_message_log, + connection_identifier)) + + return + + # Define the parameter in a dictionary which is required by the connection store. + connection_parameters = {"Host": self.connection_parameter_edit_dictionary["Host"].text(), + "Username": self.connection_parameter_edit_dictionary["Username"].text(), + "Database": self.connection_parameter_edit_dictionary["Database"].text(), + # The port is used as integer number, not as string. + "Port": int(self.connection_parameter_edit_dictionary["Port"].text()) + } + + return connection_parameters, connection_identifier, changed_password + + def commit_new_parameters_to_yaml(self): + """ + This function is called for a commit of new connection parameters. The current given connection parameters are + used, if they are valid. + """ + + # Get the current connection parameters. + connection_information = self.get_given_connection_parameters() + + # If the current parameters are not None, all parameters are valid. There is an error message from the function + # for getting the connection parameters, if not. + if connection_information is not None: + # Get the separate values of the tuple. + connection_parameters = connection_information[0] + connection_identifier = connection_information[1] + + # Use the function for committing the parameter to the .yaml file. + commit_parameters_yaml = global_connection_store.save_connection_parameters_in_yaml_file( + connection_parameters) + + # Check for the success of the commit of the parameters. + if commit_parameters_yaml is True: + # Inform the user about the success. + QMessageBox.information(self, "Commit Success", "The connection parameters for {} were committed " + "successfully.".format(connection_identifier)) + + self.update_connection_information() + + # Explain the situation to the user. + elif commit_parameters_yaml is False: + QMessageBox.warning(self, "Commit Fail", + "The connection parameters for {} could not be committed, because" + " this connection has been committed with the parameters for " + "host, port and user before. The database is irrelevant, " + "because " + "it is used as entry point and all the other databases for this " + "host, port and user are accessible with one database as entry " + "point.".format(connection_identifier)) + + def check_for_empty_parameter_edit_fields(self): + """ + Get the current text in the QLineEdits and check, if they are empty. If they are, show this as error to the + user, except for an empty password field. The password field is allowed to be empty, because database + connections without a password can exist. + """ + + # Get all items of the dictionary, which stores the name of a parameter and the corresponding QLineEdit + for edit_field_name, edit_qt_object in self.connection_parameter_edit_dictionary.items(): + # Check for every parameter except password if it contains an empty string. + if edit_field_name != "Password" and edit_qt_object.text() == "": + # Create a title for an error. + empty_field_title = "Empty field for parameter {}".format(edit_field_name) + # Create a description of the error. + empty_field_description = "The field for the parameter {} is empty. Please enter a value.".format( + edit_field_name) + + return [True, empty_field_title, empty_field_description] + + # An empty password field describes a corner case, because database connections without a password can be valid. + if self.connection_parameter_edit_dictionary["Password"].text() == "": + # Create a title for the information about an empty password. + password_empty_title = "No password supplied" + # Create a description for the information about an empty password, which implies a question. + password_empty_description = "The field for the parameter password is empty. Do you want to proceed with " \ + "a database connection without a password?" + + return [None, password_empty_title, password_empty_description] + + return False + + def check_for_valid_port(self): + """ + Check for a valid port number as an integer variable. A port can be an integer number between 0 and 65535. + """ + + # Try to initiate a check for a valid port. + try: + # Try a type convert for the current text in the QLineEdit for the port. + port_in_integer = int(self.connection_parameter_edit_dictionary["Port"].text()) + # A valid port number is between 0 and 65535 + if 0 <= port_in_integer <= 65535: + return True + + else: + return False + + # A ValueError can occur, if the current input for port in the QLineEdit is not a(n integer) number. + except ValueError: + return False + + def check_for_changed_password(self, database_connection_identifier): + """ + Check for a change in password. + """ + + # Get a password identifier based on the connection identifier. + password_identifier = database_connection_identifier.split("/")[0] + + # Check, if the password in the keyring is identical to the given password, so an extra dialog is not necessary. + if keyring.get_password(self.service_name, password_identifier) \ + == self.connection_parameter_edit_dictionary["Password"].text(): + return False + + else: + return True + + def set_password_with_its_identifier(self, database_connection_identifier): + """ + Save the given password in the password manager of the operating system based on its identifier, which contains + the name of the user, server, port and database, so a clear identification is possible. Check also for an + already existing password. + """ + + # Use the password identifier, which is used for the keyring and password storage. + password_identifier = database_connection_identifier.split("/")[0] + + if keyring.get_password(self.service_name, password_identifier) is not None: + overwrite_existing_password_title = "Overwrite existing password" + overwrite_existing_password_description = "Do you want to overwrite the already existing password for " \ + "the database connection with the identifier " \ + "{}?".format(database_connection_identifier) + + overwrite_existing_password = QMessageBox.question(self, overwrite_existing_password_title, + overwrite_existing_password_description) + + if overwrite_existing_password == QMessageBox.No: + return False + + try: + keyring.set_password(self.service_name, password_identifier, + self.connection_parameter_edit_dictionary["Password"].text()) + + return True + + except Exception as keyring_error: + logging.critical("An error occurred during the process of saving the password for {} in the password " + "manager: {}".format(database_connection_identifier, keyring_error), exc_info=True) + + return False + + def update_connection_information(self, change_information="new", position=None): + """ + Update the connection information with the custom signal of the class. There is the default change information + with the information and the position of a new connection. This is used in the tree model for updating the + connections. The list widget must also be updated. + """ + + # Emit the signal with information about modified parameters. + self.get_modified_connection_parameters.emit((self.selected_connection_parameters_dictionary, + change_information, position)) + + # Update the list widget and use the change information to specify the update. + self.update_list_widget_to_changes(change_information) + + def update_list_widget_to_changes(self, change_information): + """ + Update the list widget to the changes. The variable change information contains the type of change. A corner + case for the change information is the information "change", because with this parameter, the connection has + been updated. The connection for an update ist normally selected and in the next step, it is changed. This + function ensures the selection of the previous identifier, so the freshly changed connection needs to be found. + """ + + # Check the change type for "change". + if change_information == "change": + # Get the old list of connection identifiers for further usage, which is only necessary for the information + # about "change". + old_item_list = self.get_all_item_texts_in_list_widget() + + # Get the new and updated content to the list widget. + self.get_current_connections_to_list_widget() + + # If the selected connection identifier is not None, there was a selected item in the previous list. + if self.selected_connection_identifier is not None: + # Use the function for finding the occurrence of the previous selected item (stored in the selected + # connection identifier). If there is a match, this match is selected and returned. + identifier_in_list_widget = self.find_occurrence_in_list_widget_and_select_item( + self.selected_connection_identifier) + + # If the corner case "change" is relevant and the previous selection cannot be found, this previous + # selection may have changed. This can also happen for "delete", but after a deletion, there is no need to + # select the previous item. + if change_information == "change" and identifier_in_list_widget is None: + # Get a list of all new items. + new_item_list = self.get_all_item_texts_in_list_widget() + # Use a set to find the one difference in the old list and the new list. This statement finds the one + # item, which is part of new item list and not of old item list. This is the changed item. This item is + # stored in a list instead of a set object. + item_to_select_in_list = list(set(new_item_list) - set(old_item_list)) + # Use the function for finding a occurrence again, but this time with the different item, which is the + # changed previous item. + self.find_occurrence_in_list_widget_and_select_item(item_to_select_in_list[0]) + + def find_occurrence_in_list_widget_and_select_item(self, selection_identifier): + """ + Find the occurrence of a given identifier in the list widget and the select this identifier. Return the + identifier for possible further usage. + """ + + # Check every item in the list widget based on a for loop for the index. + for list_widget_item_range in range(self.connection_parameters_list_widget.count()): + # Get the item at the given index/range. + list_widget_item = self.connection_parameters_list_widget.item(list_widget_item_range) + + # If the text of the list widget as connection identifier is identical with the given identifier of a + # current selection, select the item in the list widget. + if list_widget_item.text() == selection_identifier: + # Select the item in the list widget. + list_widget_item.setSelected(True) + + # Return the item for possible further usage. + return list_widget_item + + def get_all_item_texts_in_list_widget(self): + """ + Get a list of all item texts in the list widget. These texts are the connection identifiers. + """ + + # Define the list. + list_widget_item_list = [] + # The count of the list widget is the number of items in this widget, so every item is checked. + for list_widget_item_range in range(self.connection_parameters_list_widget.count()): + # Append the text of every item to the list. + list_widget_item_list.append(self.connection_parameters_list_widget.item(list_widget_item_range).text()) + + # Return the freshly filled list. + return list_widget_item_list + + def test_current_database_connection(self): + """ + Test the currently available parameters in the QLineEdits and adjust the QLabels, which show the user feedback + about the connection and the ability to establish a database connection. + """ + + # Get the current text of the QLineEdits and assign them to their purpose. + host = self.connection_parameter_edit_dictionary["Host"].text() + user = self.connection_parameter_edit_dictionary["Username"].text() + database = self.connection_parameter_edit_dictionary["Database"].text() + password = self.connection_parameter_edit_dictionary["Password"].text() + port = self.connection_parameter_edit_dictionary["Port"].text() + + # Use the function of the global connection factory to test the connection parameters. + if global_connection_factory.test_parameters_for_database_connection(host, user, database, password, port) \ + is True: + # If there is a connection, use variables for further adapting of the layout. + connection_status_result = "Connection Valid" + server_status_pixmap_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", + "server_valid.svg") + + else: + # If there is not a connection, use variables for further adapting of the layout. + connection_status_result = "Connection Invalid" + server_status_pixmap_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", + "server_invalid.svg") + + # Set the text and the pixmap of the QLabels. Their text and picture is based on the results of the connection + # test. + self.test_given_connection_status_label.setText(connection_status_result) + self.add_server_pixmap(server_status_pixmap_path) + + def save_open_at_start_checkbox_configuration(self): + """ + Save the current configuration of the open at start checkbox in the dictionary of the global configurator. + """ + + global_app_configurator.set_single_configuration(self.open_at_start_configuration, + self.open_at_start_checkbox.isChecked()) + + def init_connection_order_checkbox_configuration(self): + """ + Load the current configuration of the checkbox for the order of connections in the list widget out of the .yaml + file for configuration. + """ + + # Get the current configuration, which could also be None for a non-existing configuration. + current_configuration = global_app_configurator.get_single_configuration( + self.connection_order_configuration_name) + + # If the configuration is not False, which could be True or None, proceed. + if current_configuration is not False: + # Set the checkbox to checked. + self.connection_order_checkbox.setChecked(True) + + else: + # Do not set the checkbox to checked. + self.connection_order_checkbox.setChecked(False) + + def set_connection_to_order_in_list_widget(self): + """ + Update the order of the connections in the list widget. First of all, clear the current list widget, so it is + nothing in there. After that, load the current connections in the list widget. Use the identifier of the + selected connection, which has not changed since the last selection, to find this connection in the new order + of the list widget and set the selection to this connection. After that, save the configuration of the checkbox + to the configuration file. + """ + + # Purge all current items of the list widget. + self.connection_parameters_list_widget.clear() + + # Load the items/connection to the list widget. + self.get_current_connections_to_list_widget() + + # Iterate over every element of the list widget. + for count in range(self.connection_parameters_list_widget.count()): + # Check the item at every count (and so every item in the list widget) for its text. If the text is + # identical to the previous selection identifier, it is a match. + if self.selected_connection_identifier == self.connection_parameters_list_widget.item(count).text(): + # Set the selection back to the previous selected item, which is now at another place in the list + # widget. + self.connection_parameters_list_widget.setCurrentRow(count) + + # Save the configuration of the checkbox. + self.set_connection_order_checkbox_configuration() + + def get_current_connections_to_list_widget(self): + """ + Get all current connections out of the .yaml file with its identifier to the list widget, so the user can select + them. + """ + + # Clear all current parameters, so nothing will stop a potential (re)population of the widget. + self.connection_parameters_list_widget.clear() + + # Load all current connections out of the .yaml file, which is administrated by the global connection store. + connection_parameter_list = global_connection_store.get_connection_parameters_from_yaml_file() + + # The list widget requires an index for inserting items. This variable is used for incrementation in a for loop. + connection_number_count = 0 + + # Check for the current status of the checkbox for the order of the connections in the list widget. + if self.connection_order_checkbox.isChecked(): + # If the checkbox is checked, reverse the list. The connection list is in its original order from the oldest + # connection to the newest. After the list is reversed, the first item is the newest connection. + connection_parameter_list = reversed(connection_parameter_list) + + else: + # Sort the list with the host as criteria alphabetically. + connection_parameter_list = sorted(connection_parameter_list, key=lambda k: k["Host"]) + + # Create for all parameters a connection identifier. + for connection_parameter in connection_parameter_list: + connection_identifier = "{}@{}:{}/{}".format(connection_parameter["Username"], + connection_parameter["Host"], + connection_parameter["Port"], + connection_parameter["Database"]) + + # Add the identifier with its index to the list widget. + self.connection_parameters_list_widget.insertItem(connection_number_count, connection_identifier) + # Increment the index for the next loop, so the next item is inserted under the previous item. + connection_number_count += 1 + + def set_connection_order_checkbox_configuration(self): + """ + Save the current configuration of the checkbox for the order of the connections in the list widget. First, set + the configuration in the global configurator and then, save the data in the .yaml file for configuration. + """ + + # Set the configuration with its name and the current status of the checkbox. If the status ist checked, this + # value is True. If not, it is False. + global_app_configurator.set_single_configuration(self.connection_order_configuration_name, + self.connection_order_checkbox.isChecked()) + + # Save the configuration in the .yaml file for configuration. + global_app_configurator.save_configuration_data() + + def insert_parameters_in_edit_fields(self): + """ + Insert the parameters of the selected connections to the QLineEdit fields and make commits possible with the + activation of the required button and shortcut. + """ + + # Get the selected connection with its parameters. + if self.get_selected_connection(): + # Set all parameters of the dictionary to the related fields in the QLineEdit. + self.connection_parameter_edit_dictionary["Host"].setText( + self.selected_connection_parameters_dictionary["Host"]) + self.connection_parameter_edit_dictionary["Username"].setText( + self.selected_connection_parameters_dictionary["Username"]) + self.connection_parameter_edit_dictionary["Database"].setText( + self.selected_connection_parameters_dictionary["Database"]) + self.connection_parameter_edit_dictionary["Port"].setText( + # Use the port as string, because all elements of the QLineEdit have to be strings. + str(self.selected_connection_parameters_dictionary["Port"])) + + # Define a password identifier for recognition in the password manager and keyring. + password_identifier = self.selected_connection_identifier.split("/")[0] + # Set the password as text in the QLineEdit. This is not a security flaw, because it is protected by the + # user's master password and the password mode of the QLineEdit. + self.connection_parameter_edit_dictionary["Password"].setText( + keyring.get_password(self.service_name, password_identifier)) + + # Enable the button for saving, because now, a item is selected. + self.save_connection_button.setEnabled(True) + # Enable the button for deleting a connection. + self.delete_current_connection_button.setEnabled(True) + + else: + self.new_connection_button.setEnabled(True) + self.new_connection_shortcut.setEnabled(True) + self.save_connection_button.setEnabled(False) + self.delete_current_connection_button.setEnabled(False) + + def get_selected_connection(self): + """ + Check for a selected item and if there is one, use the current selected connection identifier and build a + dictionary with the connection parameters out of it. Return the success as boolean. + """ + + # Check for a selected connection. + if self.connection_parameters_list_widget.selectedItems(): + # Use the list of selected items. This list contains one item and the text of this item is the current + # selected connection identifier. + self.selected_connection_identifier = self.connection_parameters_list_widget.selectedItems()[0].text() + + # Create a list of parameters with splitting at the different characters to get the single parameter. + parameter_list = [parameter.strip() for parameter in re.split("[@:/]", + self.selected_connection_identifier)] + + # Save the parameters in a dictionary. The position in the list is determined by the structure of the + # connection identifier and can be used for this relation. + self.selected_connection_parameters_dictionary = {"Host": parameter_list[1], + "Username": parameter_list[0], + "Database": parameter_list[3], + "Port": int(parameter_list[2])} + + # Return the success with True. + return True + + # The else branch is used for an empty list of currently selected item, so there is not a selected item. + else: + # Return a missing selected connection with false. + return False + + def delete_selected_connection_after_user_question(self): + """ + Deleting the current selected connection after asking the user for consent. The idea is, to use this function + with a delete button. Such a button could be clicked accidentally, so there is a question for the user, if they + really want to delete the connection. + """ + + # Ask the user with a message box and use the text of the currently selected item to show the user, which + # connection data will be deleted. A check for a(n un)selected connection is not necessary, because this + # function is only called with the button for deleting connections. + really_delete_connection = QMessageBox.question(self, "Delete Connection", " Do you really want to delete the " + "currently selected connection " + "{}?".format( + self.connection_parameters_list_widget.selectedItems()[ + 0].text()), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + # If the user wants to delete the connection, delete the connection. + if really_delete_connection == QMessageBox.Yes: + if self.delete_selected_connection() is True: + self.update_connection_information(change_information="delete") + + def delete_selected_connection(self): + """ + Delete the current selected connection. The parameters of the current selected connection are stored in a + class-wide dictionary, so this connection can be deleted in the .yaml file. The password needs to be deleted + too, so keyring is used for this operation. + """ + + # Use the connection store to delete the connection based on the class-wide parameter dictionary. + global_connection_store.delete_connection(self.selected_connection_parameters_dictionary) + + # Use a try except statement to delete the password in the keyring, because some errors (for a non existing user + # for example) need to be caught. + try: + # Get the password identifier. The database is not part of it, so there is a split. + password_identifier = self.selected_connection_identifier.split("/")[0] + # Delete the password with keyring. + keyring.delete_password(self.service_name, password_identifier) + + # Return True for a success. + return True + + except Exception as password_error: + logging.error("During the deletion of the password for {}, the following error occurred: " + "{}".format(self.selected_connection_identifier, password_error), exc_info=True) + + def save_connection_changes(self): + """ + Save the new connection data. + """ + + # Get the current connection information. + connection_information = self.get_given_connection_parameters() + + # Proceed, if the current connection information is not None. This variable can be None for errors during the + # validation process. + if connection_information is not None: + # Get the connection parameters and the connection identifier out of the tuple. + connection_parameters = connection_information[0] + connection_identifier = connection_information[1] + password_changed = connection_information[2] + + # Initiate a change of connection and if it was successful, proceed with the if branch. + if global_connection_store.change_connection(self.selected_connection_parameters_dictionary, + connection_parameters, password_change=password_changed): + + # Inform the user about the successful commit. + QMessageBox.information(self, "Commit Success", "The changed connection parameters for {} were " + "committed successfully.".format(connection_identifier)) + + # Get the index of the new connection parameters out of the connection store to use the index in the + # function for accepting the dialog. + index_of_new_connection = global_connection_store.get_index_of_connection(connection_parameters) + + # Use the information for a change and the index of the connection as position. + self.update_connection_information(change_information="change", position=index_of_new_connection) + + else: + QMessageBox.warning(self, "Commit Fail", + "The changed connection parameters for {} could not be committed, because" + " this connection has been committed with the parameters for " + "host, port and user before. The database is irrelevant, " + "because it is used as entry point and all the other databases for this " + "host, port and user are accessible with one database as entry " + "point.".format(connection_identifier)) + + def get_current_timeout(self): + """ + Get the current timeout time out of the app configurator. + """ + + # Get the configuration. + current_timeout = global_app_configurator.get_single_configuration("Timeout") + + # If the configuration is not None, set it as text in the line edit. + if current_timeout is not None: + self.timeout_line_edit.setText(str(current_timeout)) + + def set_current_timeout(self): + """ + Set the current value in timeout as global timeout value after a check and inform the user about the result. + """ + + # Check for a valid value. If the value is invalid, end the function with a return. + if self.check_for_valid_timeout() is False: + # Create a title and a description for an error. + timeout_input_error_title = "Invalid input for timeout" + timeout_input_error_description = "The given timeout number is invalid. Please enter an integer number " \ + "larger than 0." + # Show the error to the user in a message box. + QMessageBox.critical(self, timeout_input_error_title, timeout_input_error_description) + + return + + # Set the timeout time as integer. + global_app_configurator.set_single_configuration("Timeout", int(self.timeout_line_edit.text())) + # Save the current configuration. + global_app_configurator.save_configuration_data() + + self.new_timeout_for_connections.emit(True) + + # Inform the user about the success. + QMessageBox.information(self, "Timeout set", "The new timeout value is set successfully.") + + def check_for_valid_timeout(self): + """ + Check for a valid timeout number as an integer variable. A timeout needs to be larger than 0. + """ + + # Try to initiate a check for a valid timeout. + try: + # Try a type convert for the current text in the QLineEdit for the port. + timeout_in_integer = int(self.timeout_line_edit.text()) + # A valid timeout is larger than 0. + if timeout_in_integer > 0: + return True + + else: + return False + + # A ValueError can occur, if the current input for timeout in the QLineEdit is not a(n integer) number. + except ValueError: + return False diff --git a/pygadmin/widgets/dock.py b/pygadmin/widgets/dock.py new file mode 100644 index 0000000..43e7bc1 --- /dev/null +++ b/pygadmin/widgets/dock.py @@ -0,0 +1,31 @@ +from PyQt5.QtWidgets import QDockWidget + +from pygadmin.widgets.tree import TreeWidget + + +class DockWidget(QDockWidget): + """ + Create a class which is a child class of QDockWidget. This widget provides the possibility to be docked on a + specific side of a window. It contains the tree widget and is used as a container. + """ + + def __init__(self): + """ + Separate specific functions for initialization. + """ + + super().__init__() + self.init_ui() + + def init_ui(self): + """ + Design the user interface and its components. + """ + + # Get the tree widget. + self.tree = TreeWidget() + # Make the dock widget movable, so it can be docked to all sides and make the title bar vertical. The title bar + # contains the title of the widget. + self.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetVerticalTitleBar) + self.setWindowTitle(self.tree.windowTitle()) + self.setWidget(self.tree) diff --git a/pygadmin/widgets/editor.py b/pygadmin/widgets/editor.py new file mode 100644 index 0000000..b489abe --- /dev/null +++ b/pygadmin/widgets/editor.py @@ -0,0 +1,916 @@ +import logging +import re +import datetime + +from PyQt5.Qsci import QsciScintilla +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QEvent +from PyQt5.QtGui import QKeySequence +from PyQt5.QtWidgets import QWidget, QGridLayout, QPushButton, QTableView, QMessageBox, QShortcut, QFileDialog, \ + QCheckBox, QLabel, qApp + +from pygadmin.connectionfactory import global_connection_factory +from pygadmin.models.tablemodel import TableModel +from pygadmin.configurator import global_app_configurator +from pygadmin.models.lexer import SQLLexer +from pygadmin.database_query_executor import DatabaseQueryExecutor +from pygadmin.widgets.search_replace_widget import SearchReplaceWidget +from pygadmin.widgets.search_replace_parent import SearchReplaceParent +from pygadmin.command_history_store import global_command_history_store + + +class MetaEditor(type(QWidget), type(SearchReplaceParent)): + """ + Define a meta class for the Editor Widget for preventing a meta class conflict. The editor should implement QWidget + and an interface for providing the methods, which are required by a parent of the search replace widget. + """ + + pass + + +class EditorWidget(QWidget, SearchReplaceParent, metaclass=MetaEditor): + """ + Create a class which is a child class of QWidget as an interface for an editor window/widget. The class shows the + GUI components for entering and submitting an SQL query. The data about the database connection is received with a + pyqtSlot. + """ + + # Define a signal for a structural change in tables, views, schemas and databases. + structural_change_in_view_table = pyqtSignal(tuple) + # Define a signal for a change in the current status message. + change_in_status_message = pyqtSignal(str) + + def __init__(self): + """ + Make sub functions for initializing the widget, separated by the parts user interface, grid layout and SQL + lexer. + """ + + super().__init__() + self.init_ui() + self.init_grid() + self.init_lexer() + + def init_ui(self): + """ + Design the user interface and its components. + """ + + # Create an input field with QsciScintilla as SQL editor field. + self.query_input_editor = QsciScintilla() + + # Initialize the table model with an empty data list because data from SQL queries + self.table_model = TableModel([]) + self.table_view = QTableView() + # Use the table view with the customized table model. + self.table_view.setModel(self.table_model) + self.table_view.installEventFilter(self) + + # Set the current database connection to None because all parameters for a database connection are received by a + # signal. + self.current_database_connection = None + + # Set the connection identifier to None. + self.connection_identifier = None + + # Get a database executor. + self.database_query_executor = DatabaseQueryExecutor() + # Connect the function for new data with the refresh of the table model. + self.database_query_executor.result_data.connect(self.refresh_table_model) + # Connect the function for processing an error with the error. + self.database_query_executor.error.connect(self.process_query_error) + # Connect the new query status message with the function for checking the query status message. + self.database_query_executor.query_status_message.connect(self.check_query_status_message) + # Connect the new database connection with a function for refreshing the database connection. + self.database_query_executor.new_database_connection.connect(self.refresh_database_connection) + + self.long_description = QLabel() + + # Create a button for submitting a query. + self.submit_query_button = QPushButton("Submit Query") + self.submit_query_button.clicked.connect(self.execute_current_query) + + # Create a shortcut for submitting a query. + self.submit_query_shortcut = QShortcut(QKeySequence("F5"), self) + # Use a function for the shortcut, which checks also the validity of the connection and executes a query or + # shows an error to the user. + self.submit_query_shortcut.activated.connect(self.check_for_valid_connection_and_execute_query_with_shortcut) + + # Check for enabling and disabling of the button for submitting a query. + self.check_enabling_of_submit_button() + + # Create a button for stopping a query. + self.stop_query_button = QPushButton("Stop Query") + self.stop_query_button.clicked.connect(self.stop_current_query) + + self.stop_query_shortcut = QShortcut(QKeySequence("Ctrl+C"), self) + self.stop_query_shortcut.setEnabled(False) + self.stop_query_shortcut.activated.connect(self.stop_current_query) + + # Set the button and the shortcut for stopping a query as disabled as default, because a query only needs to be + # stopped when a query is currently executed. + self.set_stop_query_element_activate(False) + + # Set the corresponding saved file to None: This parameter will be overwritten later. + self.corresponding_saved_file = None + # Set the current text to an empty string. This text will be overwritten by the saved state of the file or the + # loaded state. + self.current_editor_text = "" + # Connect a change of text to an update of the window title. This statement changes the status of saved or + # unsaved of the current text. + self.query_input_editor.textChanged.connect(self.update_window_title_and_description) + + # Create a shortcut for saving the current text/statement in the editor. + self.save_current_sql_statement_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) + # Connect the shortcut with the function for saving the current statement in a file. + self.save_current_sql_statement_shortcut.activated.connect(self.save_current_statement_in_file) + + # Create a shortcut for opening a loaded file. + self.open_previous_file_shortcut = QShortcut(QKeySequence("Ctrl+O"), self) + # Connect the shortcut to a function with more user dialog. + self.open_previous_file_shortcut.activated.connect(self.load_file_with_potential_overwrite_in_editor) + + # Create a search replace widget with this editor widget as parent. + self.search_replace_widget = SearchReplaceWidget(self) + # Set the button disabled, because initially, there is no search, so there is no next. + self.deactivate_search_next_and_replace_buttons_and_deselect() + # Set the widget invisible, so it is only activated for a search. + self.close_search_replace_widget() + + # Define a short cut for the search dialog. + self.search_usages_shortcut = QShortcut(QKeySequence("Ctrl+F"), self) + self.search_usages_shortcut.activated.connect(self.open_search_dialog) + + # Define a short cut for the extended search dialog with a replace function. + self.replace_usages_shortcut = QShortcut(QKeySequence("Ctrl+R"), self) + self.replace_usages_shortcut.activated.connect(self.open_replace_dialog) + + self.setGeometry(600, 600, 500, 300) + + self.update_window_title_and_description() + + self.show() + + def init_grid(self): + """ + Set a grid layout to the widget and place all its components. + """ + + # Define the layout. + grid_layout = QGridLayout(self) + + grid_layout.addWidget(self.long_description, 0, 0, 1, 4) + # Set the search and replace widget. + grid_layout.addWidget(self.search_replace_widget, 1, 0, 2, 4) + + # Set the input field for SQL queries as element at the top. + grid_layout.addWidget(self.query_input_editor, 3, 0, 1, 4) + # Set the table view as window for results between the input field for SQL queries and the button for submitting + # the query. + grid_layout.addWidget(self.table_view, 4, 0, 1, 4) + # Set the submit button for the SQL queries as element at the bottom. + grid_layout.addWidget(self.submit_query_button, 5, 0, 1, 4) + # Place the stop button below the submit button. + grid_layout.addWidget(self.stop_query_button, 6, 0, 1, 4) + + grid_layout.setSpacing(10) + + self.setLayout(grid_layout) + + def init_lexer(self): + """ + Configure the lexer for SQL queries and the SQL language in general, so syntax highlighting is enabled. + """ + + # Get the custom lexer as input field for the SQL queries. + self.lexer = SQLLexer(self.query_input_editor) + # Assign the lexer to the input field for SQL queries. + self.query_input_editor.setLexer(self.lexer) + # Enable UTF8 support as common standard for encoding. + self.query_input_editor.setUtf8(True) + + @pyqtSlot(dict) + def set_connection_based_on_parameters(self, connection_parameter_dictionary): + """ + Get a database connection based on given parameters in a dictionary which could be received by a pyqtSlot. Check + also for potential errors which could occur and report them to the user. + """ + + # Use the global connection factory to get a connection which is used as current database connection of the + # class. + self.current_database_connection = global_connection_factory.get_database_connection( + connection_parameter_dictionary["host"], + connection_parameter_dictionary["user"], + connection_parameter_dictionary["database"], + connection_parameter_dictionary["port"]) + + # Define a connection identifier. Its existence is not limited by a failed connection, because it is relevant to + # know which specific connection has failed. + self.connection_identifier = "{}@{}:{}/{}".format(connection_parameter_dictionary["user"], + connection_parameter_dictionary["host"], + connection_parameter_dictionary["port"], + connection_parameter_dictionary["database"]) + + # If the database connection is None, this is caused by one specific error, which is defined by the connection + # factory with the return value None. The reason can be shown to the user as the error is mostly cause by them. + if self.current_database_connection is None: + password_none_error_topic = "Password Error" + # Specify the error by showing which user is affected. + password_none_error_message = "A password cannot be found for {}. Please check for a " \ + "given password.".format(connection_parameter_dictionary["user"]) + # Use the function for error handling. + self.connection_failed_error_handling(password_none_error_topic, password_none_error_message) + + # If the database connection is False, this is caused by a wider range of errors. + if self.current_database_connection is False: + connection_error_topic = "Connection Error" + connection_error_message = "An error occurred during the database connection process. There is a huge " \ + "range of possible reasons for example a wrong password or problems with the " \ + "database server. Please check the log for further information." + # Use the function for error handling + self.connection_failed_error_handling(connection_error_topic, connection_error_message) + + # Set the new database connection as database connection of the database query executor. + self.database_query_executor.database_connection = self.current_database_connection + + # Check for enabling or disabling the button and the shortcut for submitting a query based on the new result of + # the established connection. + self.check_enabling_of_submit_button() + + # Update the window title to the current status of the database connection. + self.update_window_title_and_description() + + def execute_current_query(self): + """ + Check for a valid connection and execute the current (selected) content of the editor in a separate thread. The + separate thread emits signals for processing the result of the query. This process is realized by the database + query executor. + """ + + # At this point, the query will be executed and the process will begin, so a signal for a status bar is emitted, + # which can be used by a main window. This method with a signal is safer than finding the main window in the + # parents of this widget. + self.change_in_status_message.emit("Executing Query") + + # Activate the button and the shortcut for stopping the current query. + self.set_stop_query_element_activate(True) + + # Get the query for executing. + query_to_execute = self.get_query_in_input_editor() + + # Define the query to execute as database query of the executor. + self.database_query_executor.database_query = query_to_execute + # Submit and execute the query with the given parameters. + self.database_query_executor.submit_and_execute_query() + + def get_query_in_input_editor(self): + """ + Get the current query out of the input editor. If there is a selected part of the text in the editor, then use + only the selected text as query. + """ + + # If the selected text contains an empty string, there is not any selected text. + if self.query_input_editor.selectedText() == "": + # The query to execute is the whole text in the input editor. + query_to_execute = self.query_input_editor.text() + + else: + query_to_execute = self.query_input_editor.selectedText() + + return query_to_execute + + def refresh_table_model(self, result_data_list, save_command=True): + """ + Refresh the table model with the given result data list. As default, the used command is saved. + """ + + # Add the given result list with its current content to the table model for showing the result. + self.table_model.refresh_data_list(result_data_list) + self.table_view.resizeColumnsToContents() + + # At this point, a new query can be executed, so the status message is changed to ready again. + self.change_in_status_message.emit("Ready") + + # Disable the button and the short cut for stopping a query, because a query is currently not executed. + self.set_stop_query_element_activate(False) + + # Check, if the command should be saved. + if save_command: + # Save the used command of the query in the command history. + self.save_command_in_history() + + def process_query_error(self, error_tuple): + """ + Get a tuple for an error. This tuple contains the title of an error and its description. This error is shown to + the user. + """ + + # Use a message box for showing an error to the user. + QMessageBox.critical(self, error_tuple[0], error_tuple[1]) + + def connection_failed_error_handling(self, error_topic, error_message): + """ + Handle a failed connection and show an adequate error to the user without a message box, because they occur so + often with a failed connection, it is truly annoying.. Use the identifier of the connection to show the user the + failed connection in the title of the widget. + """ + + # Enter the two error parameters in a list, which can be used by the table model for showing the connection + # error data again to the user. + error_result_list = [[error_topic], (error_message,)] + # Show the error in the table model and do not save the command, which caused the error. + self.refresh_table_model(error_result_list, save_command=False) + + def check_enabling_of_submit_button(self): + """ + Check for enabling or disabling the button and the shortcut for submitting a query. There is a check for a valid + connection with a specified function. + """ + + # If the connection is valid, the button is enabled. If the connection is invalid, the button is disabled. + self.submit_query_button.setEnabled(self.database_query_executor.is_connection_valid()) + + def check_query_status_message(self, status_message): + """ + Check the class wide object for a status message, which is set after executing a query and emit a signal, if the + status message contains a "TABLE" or "VIEW" or "SCHEMA". + """ + + # Check for a valid status message, which is not None. + if status_message is not None: + # Check the query status message for the occurrence of "TABLE", "VIEW", "SCHEMA" or "DATABASE". These words + # normally occur in a status message, if a table or view or schema or database is created, deleted, altered + # or dropped. As a result, the tree must be updated to the current database circumstances. + table_view_schema_pattern = re.search("TABLE|VIEW|SCHEMA|DATABASE", status_message) + + # If such a pattern is found, the variable is not None. + if table_view_schema_pattern is not None: + # Get the first result of the pattern, either the word "TABLE" or the word "VIEW" or the word "SCHEMA". + table_view_schema_pattern = table_view_schema_pattern.group(0) + # Get the parameters of the current connections. Based on the current connection, a change was made, so + # this connection needs to be updated and informed about the change. + database_connection_parameters = global_connection_factory.get_database_connection_parameters( + self.current_database_connection) + + # Emit the pattern and the connection parameters, so a slot can received them. + self.structural_change_in_view_table.emit((table_view_schema_pattern, database_connection_parameters)) + + def stop_current_query(self): + """ + Stop the current query with a function of the database query executor. + """ + + self.database_query_executor.cancel_current_query() + + def set_stop_query_element_activate(self, activation): + """ + (De)activate the GUI elements for stopping a query with a bool, which contains the relevant True/False + parameter. + """ + + # Check for a bool. + if isinstance(activation, bool): + # (De)activate the button and the shortcut. + self.stop_query_button.setEnabled(activation) + self.stop_query_shortcut.setEnabled(activation) + + def save_current_statement_in_file(self): + """ + Save the current text/statement of the lexer as query editor in for further usage. The class-wide variable for + the corresponding file is used as directory with file. If this variable contains its initialized value None, + use the function for opening a file dialog. + """ + + # Check if the class-wide variable for the corresponding file is None. + if self.corresponding_saved_file is None: + # Open a file dialog and if the result is False, the process has been aborted. + if self.activate_file_dialog_for_saving_current_statement() is False: + # End the function with a return. + return + + # Open the file in the write mode, so every content is also overwritten. + with open(self.corresponding_saved_file, "w") as file_to_save: + # Define the current text of the query input editor as current text. + current_text = self.query_input_editor.text() + # Write the current text of the lexer as SQL editor in the file. + file_to_save.write(current_text) + # Save the current text in the class-wide current editor text. + self.current_editor_text = current_text + + # Update the current window title. + self.update_window_title_and_description() + + def activate_file_dialog_for_saving_current_statement(self): + """ + Activate a file dialog, so the current widget can be saved with the name and directory chosen by the user. Use + the class wide variable for the corresponding file as container for the file name. Return the success of the + operation with a boolean, because the process can be aborted by the user. + """ + + # Create a file dialog for saving the content of the current editor tab. + file_dialog_with_name_and_type = QFileDialog.getSaveFileName(self, "Save File") + # The variable file_dialog_with_name_and_type is a tuple. The zeroth variable of the tuple contains the name + # of the saved file, while the first one contains the valid types for a file. + file_name = file_dialog_with_name_and_type[0] + + # The file name contains an empty string, the process for saving a file was aborted, so this if branch is + # only activate for the purpose of the user to save a file. + if file_name != "": + # Define the corresponding file as given file name. + self.corresponding_saved_file = file_name + + # Return the success. + return True + + # This else branch is activate for an aborted process in the QFileDialog for saving the file. + else: + logging.info("The current file saving process was aborted by the user, so the current editor tab is " + "not saved.") + + # Return the abortion. + return False + + def load_statement_out_of_file(self): + """ + Load a previous saved file with the help of QFileDialog. Save the file name in the class-wide variable for + the corresponding file. Report the success of the function with a boolean. + """ + + # Get the file name and the types of the file. + file_name_and_type = QFileDialog.getOpenFileName(self, "Open File") + # Get the file name out of the tuple. + file_name = file_name_and_type[0] + + # Check for the success in form of an existing file and not an empty string. + if file_name != "": + # Save the name of the file in the class variable for the corresponding file. + self.corresponding_saved_file = file_name + + # Open the file in reading mode. + with open(self.corresponding_saved_file, "r") as file_to_load: + # Read the whole given file and save its text. + file_text = file_to_load.read() + # Show the content of the file as text in the lexer as SQL query editor. + self.query_input_editor.setText(file_text) + # Save the text of the file in the class-wide variable for the current text to check for changes and + # get the current state of saved/unsaved. + self.current_editor_text = file_text + + # Update the window title + self.update_window_title_and_description() + + # Report the success with a return value. + return True + + else: + logging.info("The current file opening process was aborted by the user, so the content of this file is not " + "loaded.") + + # Report the failure with a return value. + return False + + def load_file_with_potential_overwrite_in_editor(self): + """ + Load an existing file in the editor widget. This function is a wrapper for load_statement_out_of_file with more + user interaction under specific circumstances. If the editor is not empty and a global configuration for + allowing the overwrite without questioning is not set, ask the user. + """ + + # Check for an empty editor and the global configuration for overwriting. The check for an empty and not a + # saved editor is to avoid the annoying effect of opening a file again, caused by an overwrite of the content in + # the widget. + if self.is_editor_empty() is False and \ + global_app_configurator.get_single_configuration("always_overwrite_editor") is not True: + # Use the custom message box and it's result. If an overwrite is not required, end the function with a + # return. + if self.use_custom_message_box_for_user_feedback_about_editor_content_overwrite() is False: + # End the function with a return. + return + + # Load the statement out of the file. At this point, the user confirmed their acceptance of an overwrite. + self.load_statement_out_of_file() + + def use_custom_message_box_for_user_feedback_about_editor_content_overwrite(self): + """ + Create a custom message box for asking the user about a potential overwrite. A custom message box is necessary + for the usage of the checkbox. The user can chose between an overwrite of the current content in the editor and + the persistence of the content. A checkbox is used for a configuration, so the user can decide to overwrite all + files as default. The return value of the function is the answer to the question of the message box. + """ + + # Create a custom message box with the editor widget as parent. + custom_message_question_box = QMessageBox(parent=self) + # Set the title to a short form of the question. + custom_message_question_box.setWindowTitle("Overwrite Editor?") + # Set the text to a longer form of the question. + custom_message_question_box.setText("Do you want to overwrite the current editor content?") + # Use two standard buttons: A button for yes and a button for no. + custom_message_question_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + # Set the icon to the typical QMessageBox question icon. + custom_message_question_box.setIcon(QMessageBox.Question) + # Define the checkbox with its text and function: Always overwrite the current editor and stop questioning. + self.overwrite_editor_always_checkbox = QCheckBox("Always overwrite current editor") + # Connect the state change to a function for setting the editor configuration. + self.overwrite_editor_always_checkbox.stateChanged.connect(self.set_always_overwrite_editor_configuration) + # Set the checkbox as part of the message box. + custom_message_question_box.setCheckBox(self.overwrite_editor_always_checkbox) + # Execute the message box. This is preferred over show(), so the rest of the function is executed after closing + # the message box. + custom_message_question_box.exec_() + + # Save the new data in the global app configurator. + global_app_configurator.save_configuration_data() + + # Check the result of the clicked button, which closed the message box. "&Yes" is returned by Qt for clicking + # the yes button. + if custom_message_question_box.clickedButton().text() == "&Yes": + # Return True for a yes. + return True + + else: + # Return False for a no. + return False + + def is_editor_empty(self): + """ + Check the editor widget for its own potential emptiness. An editor is empty, if the title is the default title + and the field for a query is without text. + """ + + # Check the title and the text for emptiness. + if self.windowTitle() == "" and self.query_input_editor.text() == "": + # Return True for emptiness. + return True + + else: + # Return False for missing emptiness. + return False + + def set_always_overwrite_editor_configuration(self): + """ + Set the configuration for always overwriting the editor configuration. + """ + + global_app_configurator.set_single_configuration("always_overwrite_editor", + self.overwrite_editor_always_checkbox.isChecked()) + + def update_window_title_and_description(self): + """ + Update the window title of the editor. The window title is determined by three factors: The current database + connection, the corresponding saved file and the saved/unsaved status of the current text in the editor. + """ + + # Get the two different file names, the long version and the short version. + file_name_data = self.get_corresponding_file_name_string_for_window_title_and_description() + # Get the short name out of the tuple. + short_file_name = file_name_data[0] + # Get the full name ouf of the tuple. + full_file_name = file_name_data[1] + + # Check, if the full name of the file is not an empty string. + if full_file_name != "": + # Format the file name for the title. + formatted_file_name = " - {}".format(full_file_name) + + # If the full file name is an empty string, use as formatted file name the empty string. + else: + formatted_file_name = full_file_name + + # Create a new description title, which is the long title and add an HTML tag for a bold database connection. + new_description_title = "{}{}".format(self.get_connection_status_string_for_window_title(), + formatted_file_name) + + # If the short file is not an empty string, use it for the window title. + if short_file_name != "": + # Use the short name with the current state as window title. + new_window_title = "{}{}".format(short_file_name, self.get_file_save_status_string_for_window_title()) + + # If the short file is an empty string, use the connection status as window title. + else: + new_window_title = self.get_connection_status_string_for_window_title() + + # Set the new window title. + self.setWindowTitle(new_window_title) + + # Set the new title of the description. + self.long_description.setText(new_description_title) + + def get_connection_status_string_for_window_title(self): + """ + Get the current connection status as string based on the connection identifier. + """ + + # If the connection has failed, the current database connection would be False. The second case is also for a + # failed database connection. The connection identifier needs to be checked, because the current database + # connection and the connection identifier are initialized with None. So if the connection identifier is not + # None, there is a try to establish a database connection. + if self.current_database_connection is False or (self.current_database_connection is None and + self.connection_identifier is not None): + + # Set the connection status as failed, because the previous if-statement suggests a failed database + # connection. Use the connection identifier as description for the failed connection. + connection_status = "Connection failed: {}".format(self.connection_identifier) + + # If the current database connection is not None and not False, the connection is valid and successfully + # established. + elif self.current_database_connection is not False and self.current_database_connection is not None: + # Set the connection status to the valid connection as connection identifier. + connection_status = "{}".format(self.connection_identifier) + + # This else-branch describes the behavior for the connection status before there was even the try to establish a + # database connection. + else: + # Set the connection status to an empty string. + connection_status = "" + + # Return the result of the current connection status. + return connection_status + + def get_corresponding_file_name_string_for_window_title_and_description(self): + """ + Get the name of the corresponding file as string with the appropriate format for the prospective window title + and get the title for the description. + """ + + # Check the class-wide variable for the saved file, which is initialized as None. So this branch is only + # activated for a determined corresponding saved file. + if self.corresponding_saved_file is not None: + # Use the full title of the file as full name with a -. + full_file_name = "{}".format(self.corresponding_saved_file) + # Split the name at "/", so there are shorter parts of name. + split_list = full_file_name.split("/") + # Get the last item of the list as file name. + short_file_name = split_list[len(split_list) - 1] + + # Activate the else-branch for a non-determined corresponding saved file. + else: + # Use an empty string for the corresponding file name. + short_file_name = "" + full_file_name = "" + + # Return the result of the current corresponding file name and the full name. + return short_file_name, full_file_name + + def get_file_save_status_string_for_window_title(self): + """ + Get the current status for a saved/(n) unsaved file: If the text has not changed after the last save point or + after loading the file, there is nothing, which needs to be saved. If something has changed, there is an + information in the save status. + """ + + # If the saved current editor text is not the text in the query editor, proceed. + if self.current_editor_text != self.query_input_editor.text(): + # In this case, the current state has not been saved. + save_status = " (*)" + + # There is nothing new to save. + else: + # Set the information to an empty string, because everything is saved. + save_status = "" + + # Return the save status. + return save_status + + def check_for_valid_connection_and_execute_query_with_shortcut(self): + """ + Check for a valid database connection. If the connection is valid, execute the query. If the connection is + invalid, show an error to the user. The connection is mostly invalid, if a database is not chooen. + """ + + # Check for a valid connection. + if self.database_query_executor.is_connection_valid(): + # Submit and execute the query. + self.execute_current_query() + + # Proceed for an invalid connection. + else: + # Show an error as message box to the user. + QMessageBox.critical(self, "Connection Invalid", "The current database connection is invalid. Please choose" + " a valid database for executing a query.") + + def refresh_database_connection(self, new_connection): + """ + Get a new database connection and set the connection as current connection. This function is used by a signal + from the database query executor, if the current connection is invalid and a new one is established. + """ + + self.current_database_connection = new_connection + + def eventFilter(self, source, event): + """ + Implement the function for filtering an event: This event checks for a keypress event. If the sequence of the + keypress event matches the sequence for copy, the function for copying the current selection of the table is + used. + """ + + # Check for the correct event type. + if event.type() == QEvent.KeyPress and event.matches(QKeySequence.Copy): + # Copy the current selection of the table. + self.copy_current_table_selection() + + # Return True for a success, which is a necessary part for Qt. + return True + + # Prevent a type error with the return of this function. + return super(EditorWidget, self).eventFilter(source, event) + + def copy_current_table_selection(self): + """ + Get the current selected indexes/values of the table view and copy them to the clipboard. + """ + + # Get the selected values. + selected_values = self.table_view.selectedIndexes() + + # Proceed, if these values exist. + if selected_values: + # Get the index of all selected rows. + selected_row_indexes = [index.row() for index in selected_values] + # Sort them for further usage, so the smallest number is the first element in the list. + selected_row_indexes.sort() + + # Get the index of all selected columns. + selected_columns_indexes = [index.column() for index in selected_values] + # Sort them for further usage. + selected_columns_indexes.sort() + + # Define the string for the clipboard text. + clipboard = "" + # Define a row count for the start row. + row_count = 0 + + # Iterate over every selected value/index. + for index in selected_values: + # Get the relevant row number. + row = index.row() - selected_row_indexes[0] + + # If the row count is not equal to the relevant row number, proceed. In this case, the current column + # has ended and there is a new one. + if row_count != row: + # Add a new line for a new column to the clipboard text. + clipboard += "\n" + # Set the row count to the current new row. + row_count = row + + # Get the data at the current index. + data = index.data() + # Add the data to the clipboard. + clipboard += data + # Add a tab to the clipboard, because this data cell has ended. + clipboard += "\t" + + # Add the full text to the system clipboard. + qApp.clipboard().setText(clipboard) + + def open_search_dialog(self): + """ + Set the GUI components for the search dialog to (not) visible, depending on their current state. + """ + + # Set the simple search widget visible. + self.search_replace_widget.set_widget_visible(False) + # Check for a selected text in the editor and use it for the search field. + self.set_current_selection_to_search_replace() + + def open_replace_dialog(self): + """ + Open the extended search dialog/replace dialog. + """ + + # Set the widget with all components visible. + self.search_replace_widget.set_widget_visible(True) + # Check for a selected text in the editor and use it for the search field. + self.set_current_selection_to_search_replace() + + def set_current_selection_to_search_replace(self): + """ + Get the current selected text of the query input editor. If there is a selected text, use it for the first + search and set it as text in the search line edit. + """ + + # Get the current selected text in the input editor. + current_selected_editor_text = self.query_input_editor.selectedText() + + # Proceed for selected text. + if current_selected_editor_text != "": + # Set the selected text in the QLineEdit for searching. + self.search_replace_widget.set_search_text(current_selected_editor_text) + # Search for the current selected string. + self.search_and_select_sub_string() + + def replace_current_selection(self): + """ + Get the current text in the replace line edit and use the function of the query input editor for replacing the + current search result. + """ + + # Get the text in the replace line edit as text for replacing. + replace_text = self.search_replace_widget.get_replace_text() + # Replace the current search result with the replace text. + self.query_input_editor.replace(replace_text) + + def replace_all_sub_string_matches(self): + """ + Replace all occurrences of the search result. Use the function for finding the next result. This function + returns True, if there is still a match. + """ + + # Replace a match, if there is a search result. + while self.query_input_editor.findNext(): + self.replace_current_selection() + + # Deactivate the replace buttons after the process for replacing all sub string matches. + self.search_replace_widget.deactivate_replace_buttons() + + def search_and_select_sub_string(self): + """ + Search the first occurrence of the given sub string in the search line edit and select it, which is done by the + function findFirst. + """ + + # The text is the current text of the line edit. The expression is interpreted as regular expression and it is + # case sensitive. It is searched for any matching text and the search wraps around the end of the text. Save the + # result in a variable. + match_found = self.query_input_editor.findFirst(self.search_replace_widget.get_search_text(), True, True, + False, True) + + # Activate the relevant buttons, if a match is found. + if match_found: + self.search_replace_widget.activate_search_next_button() + + # Check, if replace enabling is available. + self.check_for_replace_enabling() + + def search_and_select_next_sub_string(self): + """ + Find the next occurrence of the given sub string. + """ + + self.query_input_editor.findNext() + + def deactivate_search_next_and_replace_buttons_and_deselect(self): + """ + Deactivate the button for searching the next item and deselect the current selection. + """ + + # Deactivate the next button. + self.search_replace_widget.deactivate_search_next_button() + + # Deactivate the replace buttons. + self.search_replace_widget.deactivate_replace_buttons() + + # Remove the current selection, because, for example, the text in the QLineEdit for searching has changed. + self.query_input_editor.setSelection(0, 0, 0, 0) + + def check_for_replace_enabling(self): + """ + Check if the replacing should be enabled. If the search input text is not empty, so there is a search, at least + one button can be enabled. If the replace text does not contain a sub string of the search input text, replace + all is available too. + """ + + # Get the text out of the search line edit. + search_input_text = self.search_replace_widget.get_search_text() + # Get the text out of the replace line edit. + replace_line_edit_text = self.search_replace_widget.get_replace_text() + + # Check if the search input text is not empty. + if search_input_text != "": + # The search input text must not be in the replace text for enabling all buttons, because for the replace + # all button, this could lead to an endless loop. + if search_input_text not in replace_line_edit_text: + self.search_replace_widget.activate_replace_buttons() + + # If the search input text is a sub string of the replace text, enable the button for a single replace and + # set the one for replacing all to disabled. + else: + self.search_replace_widget.activate_replace_button() + self.search_replace_widget.deactivate_replace_all_button() + + # If the search field is empty, deactivate all replace buttons. + else: + self.search_replace_widget.deactivate_replace_buttons() + + def close_search_replace_widget(self): + """ + Close the search widget by setting all relevant components to invisible. + """ + + # Make the relevant widget invisible. + self.search_replace_widget.set_widget_invisible() + + def save_command_in_history(self): + """ + Save the current executed command in the command history. + """ + + # Define a dictionary with the relevant command data. The command itself is the query text in the input editor. + command_dictionary = {"Command": self.get_query_in_input_editor(), + # Get the current date and time. The date is used and the current time with hours, minutes + # and seconds. + "Time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + # Get the current connection identifier as identifier. + "Identifier": self.connection_identifier} + + # Save the dictionary in the yaml file. + global_command_history_store.save_command_history_in_yaml_file(command_dictionary) + diff --git a/pygadmin/widgets/editor_appearance_settings.py b/pygadmin/widgets/editor_appearance_settings.py new file mode 100644 index 0000000..48194d3 --- /dev/null +++ b/pygadmin/widgets/editor_appearance_settings.py @@ -0,0 +1,358 @@ +from PyQt5.QtWidgets import QDialog, QPushButton, QGridLayout, QColorDialog, QLabel, QListWidget, QMessageBox, \ + QInputDialog + +from pygadmin.configurator import global_app_configurator +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class EditorAppearanceSettingsDialog(QDialog): + def __init__(self): + super().__init__() + self.setModal(True) + # Add the pygadmin icon as window icon. + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + self.init_ui() + self.init_grid() + + def init_ui(self): + """ + Initialize the user interface with its components. + """ + + self.choose_description_label = QLabel("Choose a theme to change") + # Create a label with the description of the current option. + self.description_label = QLabel("Change the current editor colors") + + # Show the current default theme. + self.current_default_theme_label = QLabel("Current default theme: None") + + # Get the current default theme with its information. + current_default_theme = global_app_configurator.get_default_color_theme_style() + + # Set the correct default theme, if it exists. + if current_default_theme is not None: + self.current_default_theme_label.setText("Current default theme: {}".format(current_default_theme[0])) + + # Define a list with all color types. + color_change_list = ["default_paper_color", "default_color", "keyword_color", "number_color", + "other_keyword_color", "apostrophe_color"] + + # Define a dictionary for saving the following items. + self.color_items_dictionary = {} + + # Create items for every color description. + for color_description_item in color_change_list: + # Define a label with a human readable appearance. + description_label = QLabel(color_description_item.replace("_", " ").title()) + # Create a button for changing the color. + change_color_button = QPushButton("Change Color") + # Connect the button with the activation of a color dialog. + change_color_button.clicked.connect(self.activate_color_dialog) + # Save the GUI elements in the dictionary with the description as key. + self.color_items_dictionary[color_description_item] = [description_label, change_color_button] + + # Create a list widget and initialize it. + self.init_list_widget() + + # Define a button for saving the current state. + self.save_changes_button = QPushButton("Save") + # Connect the button with the function for saving the state and closing the dialog. + self.save_changes_button.clicked.connect(self.save_changes_and_close) + # Define a button for closing the dialog. + self.cancel_button = QPushButton("Cancel") + # Connect the button with the function for closing and checking for changes before. + self.cancel_button.clicked.connect(self.check_for_changes_before_close) + # Define a button for adding a new theme. + self.add_new_theme_button = QPushButton("Add New Theme") + # Connect the button with the function for adding a new theme. + self.add_new_theme_button.clicked.connect(self.add_new_color_theme) + # Define a button for setting the current selected theme as default. + self.set_as_default_theme_button = QPushButton("Set As Default") + # Connect the button with the function for setting a new default theme. + self.set_as_default_theme_button.clicked.connect(self.set_default_theme) + + # Adjust the size of the dialog. + self.setMaximumSize(720, 300) + self.showMaximized() + + self.setWindowTitle("Edit Editor Appearance") + self.show() + + def init_grid(self): + """ + Place the components of the user interface with a grid layout. + """ + + # Get a grid layout. + grid_layout = QGridLayout(self) + + grid_layout.addWidget(self.choose_description_label, 0, 0) + + grid_layout.addWidget(self.current_default_theme_label, 1, 0) + + grid_layout.addWidget(self.current_themes_list_widget, 2, 0, 6, 2) + + # Add the description label. + grid_layout.addWidget(self.description_label, 0, 2) + + # Use an incrementer for the next for loop. + grid_incrementer = 2 + + # Place every value of the dictionary on the grid. + for color_item_value in self.color_items_dictionary.values(): + # Place the label on the left. + grid_layout.addWidget(color_item_value[0], grid_incrementer, 2) + # Place the button on the right. + grid_layout.addWidget(color_item_value[1], grid_incrementer, 3, 1, 2) + # Increase the value of the incrementer for correct placing of the next components. + grid_incrementer += 1 + + # Place the buttons. + grid_layout.addWidget(self.add_new_theme_button, 8, 0) + grid_layout.addWidget(self.set_as_default_theme_button, 8, 1) + grid_layout.addWidget(self.cancel_button, 8, 3) + grid_layout.addWidget(self.save_changes_button, 8, 4) + + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def init_list_widget(self): + """ + Create a list widget for the existing themes and get all current themes out of the global app configurator. Load + all current themes in the list widget with the special function for this. + """ + + # Create the list widget. + self.current_themes_list_widget = QListWidget() + self.current_themes_list_widget.itemSelectionChanged.connect(self.show_colors_for_current_selected_theme) + # Get all existing and saved themes. + self.current_color_themes_dictionary = global_app_configurator.get_all_current_color_style_themes() + # Load all current themes in the list widget. + self.load_all_current_themes_in_list_widget() + + def load_all_current_themes_in_list_widget(self, item_to_select=None): + """ + Load all current existing themes out of the class-wide dictionary in the list widget. If an item for selecting + is not given, select the default theme. + """ + + # Clear the list widget, so old items are not as duplicate in the widget after an update. + self.current_themes_list_widget.clear() + + # Use a count for inserting the items in the list widget, because such a count is required for placing. + theme_number_count = 0 + + # Iterate over every key. + for color_theme_name in self.current_color_themes_dictionary.keys(): + # Set the color theme name with the count in the list widget. + self.current_themes_list_widget.insertItem(theme_number_count, color_theme_name) + # Increase the count for the place of the next item. + theme_number_count += 1 + + # If the item to select is None (which is the default), use the default theme. + if item_to_select is None: + # Get all the information about the default theme. + default_theme_information = global_app_configurator.get_default_color_theme_style() + # Proceed, if the default theme information is not None. The default theme information is None for an empty + # default theme. + if default_theme_information is not None: + self.default_theme = default_theme_information[0] + # Select the default theme. + self.set_selected_item_in_list_widget(self.default_theme) + + # If an item is given, proceed. + else: + # Select the given item. + self.set_selected_item_in_list_widget(item_to_select) + + def set_selected_item_in_list_widget(self, item_to_select): + """ + Find the given item in the list widget and select it. + """ + + # Iterate over every item in the list widget with the index. + for item_index in range(self.current_themes_list_widget.count()): + # If the text of an item is the given item for selection, proceed. + if self.current_themes_list_widget.item(item_index).text() == item_to_select: + # Set the match as selected item. + self.current_themes_list_widget.item(item_index).setSelected(True) + + def show_colors_for_current_selected_theme(self): + """ + Get the colors of the current selected themes and color the QLabels, which show a description of the color. + """ + + # Get a new selected item. + self.get_selected_item_in_list_widget() + + # Check for a selected item. Normally, there should be one. + if self.selected_list_widget_item: + color_description_with_value = self.current_color_themes_dictionary[self.selected_list_widget_item] + + # Iterate over the color description and use the description to get the color value. The color value can now + # be used for the function for changing the color of the description label of a color. + for color_description in self.color_items_dictionary.keys(): + self.change_color_description_label_color(color_description_with_value[color_description], + color_description) + + def get_selected_item_in_list_widget(self): + """ + Get the current selected item in the list widget and store it in a class-wide variable. + """ + + # Check for a selected item/index. + if self.current_themes_list_widget.selectedIndexes(): + # Get the item at the first selected index. The item is a QListWidgetItem. The text of the item is + # necessary. The text of the item is the name of the current selected theme. + self.selected_list_widget_item = self.current_themes_list_widget.selectedItems()[0].text() + + # Report the success. + return True + + # If there is not a selected item, set the value to None. + else: + self.selected_list_widget_item = None + + return False + + def change_color_description_label_color(self, color_string, color_description): + """ + Change the color of a description for showing the current color. + """ + + # Get the two GUI elements related to the description. + color_gui_elements = self.color_items_dictionary[color_description] + # Get the description label. + description_label = color_gui_elements[0] + # Set a new text of the description label: The color of the font is set with HTML tags as possible way + # for setting the color of a text in a QLabel. The text is more readable for humans: The _ in the + # description is replaced by a space and the first letter is capitalized. + description_label.setText("{}".format(color_string, + color_description.replace("_", " ").title())) + + def activate_color_dialog(self): + """ + Activate a QColor dialog, so a new color is chosen by the user. The color is used and set as new color for + the description to the related button. + """ + + # Open a new color dialog. + color_dialog = QColorDialog() + # Get the chosen color by the user. A color is always returned by the color dialog. + chosen_color = color_dialog.getColor() + + # Check for a valid color: The color is valid, if the user does not cancel the dialog and chooses a color. + if chosen_color.isValid(): + # Get the name of the color. + chosen_color_string = chosen_color.name() + + # Iterate over the items of the dictionary with the items. + for color_description, color_gui_element in self.color_items_dictionary.items(): + # Check the first GUI element for equality with the sender: This is the button, which started the + # function. The button as GUI element is connected with the description in the dictionary. + if color_gui_element[1] == self.sender(): + # Get the dictionary of the current theme. + theme_dictionary = self.current_color_themes_dictionary[self.selected_list_widget_item] + # Set the value of the color description to the new chosen color with its string. + theme_dictionary[color_description] = chosen_color_string + + # Update the appearance in the dialog. + self.show_colors_for_current_selected_theme() + + def save_changes_in_configuration_and_apply(self): + """ + Save the changes in the configuration in the style dictionary and in the configuration dictionary. + """ + + # Use every name and the related values in the dictionary for all themes. + for color_theme_name, color_values in self.current_color_themes_dictionary.items(): + # Add the theme name and the color values as single style. + global_app_configurator.add_style_configuration(color_theme_name, color_values) + + # Save all the style data. + global_app_configurator.save_style_configuration_data() + + # Save the configuration data. + global_app_configurator.save_configuration_data() + + return True + + def save_changes_and_close(self): + """ + Save the current changes and close the dialog. + """ + + # Save the information and use the result for a user information. + if self.save_changes_in_configuration_and_apply(): + # Inform the user about a necessary restart for applying the changes. + QMessageBox.information(self, "Please Restart", + "Please restart pygadmin to apply the changes in the editor theme.") + + # Close the dialog. + self.close() + + def check_for_changes_before_close(self): + """ + Check for recent unsaved changes. + """ + + # Check for unsaved themes. + if self.current_color_themes_dictionary != global_app_configurator.get_all_current_color_style_themes(): + # Ask the user to proceed with the deletion of changes. + cancel_with_unsaved_changes = QMessageBox.question(self, "Close with unsaved changes?", + "Do you want to close with unsaved changes, which will " + "be deleted?") + + # Check for the users answer. + if cancel_with_unsaved_changes == QMessageBox.No: + # If the user does not want to proceed and delete unsaved changes, end the function with a return. + return + + # Close at this point with consent or without changes. + self.close() + + def add_new_color_theme(self): + """ + Add a new color theme with a name given by the user and default values for the colors, so the user can use the + default colors for changes. + """ + + # Define a new theme name for triggering the next while loop. Normally, the result after an input dialog shows + # the given name and the clicked button, True for Ok and False for Cancel. + new_theme_name = ("", True) + + # Use the while loop for correct user input. Correct user input is a cancel or a non-empty string. + while new_theme_name[0] == "" and new_theme_name[1] is True: + # Get the name by the user. + new_theme_name = QInputDialog.getText(self, "Theme Name", "Enter the name of the new theme") + + # If the input is not canceled, proceed. + if new_theme_name[1] is True: + # Set the name theme with default colors. + self.current_color_themes_dictionary[new_theme_name[0]] = { + "default_color": "#ff000000", + "default_paper_color": "#ffffffff", + "keyword_color": "#ff00007f", + "number_color": "#ff007f7f", + "other_keyword_color": "#ff7f7f00", + "apostrophe_color": "#ff7f007f" + } + + # Load all current themes in the list widget and select the new one. + self.load_all_current_themes_in_list_widget(item_to_select=new_theme_name[0]) + + def set_default_theme(self): + """ + Set a new, default theme. + """ + + # Get a new selected item and proceed for a success. + if self.get_selected_item_in_list_widget() is not None: + # Define the default theme as selected list widget item. + self.default_theme = self.selected_list_widget_item + # Set the configuration. + global_app_configurator.set_single_configuration("color_theme", self.default_theme) + # Set a new text to the label. + self.current_default_theme_label.setText("Current default theme: {}".format(self.default_theme)) + diff --git a/pygadmin/widgets/main_window.py b/pygadmin/widgets/main_window.py new file mode 100644 index 0000000..475abe6 --- /dev/null +++ b/pygadmin/widgets/main_window.py @@ -0,0 +1,500 @@ +import os +import logging + +from PyQt5 import QtCore +from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtWidgets import QMainWindow, QAction, QToolBar, QMessageBox, QMenu +from PyQt5.QtCore import Qt, pyqtSlot + +import pygadmin +from pygadmin.widgets.command_history import CommandHistoryDialog +from pygadmin.widgets.mdi_area import MdiArea +from pygadmin.widgets.dock import DockWidget +from pygadmin.widgets.connection_dialog import ConnectionDialogWidget +from pygadmin.widgets.configuration_settings import ConfigurationSettingsDialog +from pygadmin.widgets.editor_appearance_settings import EditorAppearanceSettingsDialog +from pygadmin.widgets.version_information_dialog import VersionInformationDialog +from pygadmin.widgets.start_progress_dialog import StartProgressDialog +from pygadmin.configurator import global_app_configurator +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class MainWindow(QMainWindow): + """ + Create a class for administration of the main interface. Every widget is showed or at least controlled by the main + window. + """ + + def __init__(self): + """ + Make sub functions for the initializing process. + """ + + super().__init__() + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + self.show() + self.init_ui() + + def init_ui(self): + """ + Create the possibility to enter connection parameters and use them instantly before the main application is + loaded with its functionality. + """ + + # Check the current configuration. If the configuration is set to True or if the configuration is not found and + # currently not part of the configuration file and so, the value is None, a connection dialog is opened. + if global_app_configurator.get_single_configuration("open_connection_dialog_at_start") is not False: + # Create a connection dialog widget. + self.connection_dialog = ConnectionDialogWidget() + + # Set the widget as central widget. + self.setCentralWidget(self.connection_dialog) + + # Set the window title as title of the widget. + self.setWindowTitle(self.connection_dialog.windowTitle()) + + # Initialize the main interface after the widget for entering connection parameter is closed. + self.connection_dialog.finished.connect(self.init_main_ui) + + else: + self.init_main_ui() + + def init_main_ui(self): + """ + Design the main user interface and its components. + """ + + # Activate the progress dialog for starting. + self.start_progress_dialog = StartProgressDialog() + + # Resize to a specific size, which is big enough to see all the relevant content. Resizing is used at this point + # so loaded content is already shown in this size. + self.resize(1280, 720) + + # Set the title with the name of the application at the current point, so a potential existing title is + # overwritten. + self.setWindowTitle("Pygadmin") + + # Use a function for configuring the menu bar as part of the ui. + self.init_menu_bar() + + # Set the current text of the status bar to ready. + self.show_status_bar_message("Ready") + + # Create the MdiArea widget. + self.mdi_area = MdiArea() + # Set the MdiArea widget as central widget, because it contains the editor component as main field for + # interaction with the program. + self.setCentralWidget(self.mdi_area) + # Generate one editor tab for the start of the application with the saved text. + self.activate_new_editor_tab() + + # Create the dock widget. + self.dock_widget = DockWidget() + # Display dock widget at the left side of the window. + self.addDockWidget(Qt.LeftDockWidgetArea, self.dock_widget) + + # Connect the signal for a new added node with the slot of the start progress dialog for getting a new step size + # and a progress in the progress bar. + self.dock_widget.tree.new_node_added.connect(self.start_progress_dialog.get_new_step_size) + + # Connect the signal for changed database connection parameters in the tree with the corresponding signal in the + # editor, so if the node in the tree is changed, the database connection in the editor is adjusted. + self.dock_widget.tree.database_parameter_change.connect( + self.mdi_area.change_current_sub_window_and_connection_parameters) + + # Connect the signal for changed database connection parameter in the editor widget(s) with the corresponding + # slot in the tree, so if the editor tab is changed, the position in the tree is adjusted. + self.mdi_area.current_sub_window_change.connect(self.dock_widget.tree.select_node_for_database_parameters) + + self.init_tool_bar() + + # Ensure the right deletion order for closing the application and prevent a warning with QTimer and QThread. + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + # Load the initial data/server nodes in the tree widget. + self.dock_widget.tree.init_data() + + def init_menu_bar(self): + """ + Initialize the menu bar as part of the user interface with its tasks and points. + """ + + # Get the menu bar. + self.menu_bar = self.menuBar() + + # Make a menu point for tasks related to "edit". + self.edit_menu = self.menu_bar.addMenu("Edit") + + # Add an action for creating a new editor. + self.add_action_to_menu_bar("New Editor", self.activate_new_editor_tab) + # Add an action for saving the current editor. + self.add_action_to_menu_bar("Save Current Editor", self.save_current_editor_widget_statement) + # Add an action for saving the current editor with a specific name. A parameter needs to be passed. This is + # realized with the usage of a lambda function. + self.add_action_to_menu_bar("Save Current Editor as", lambda: self.save_current_editor_widget_statement(True)) + # Add an action for loading a file to an editor widget. + self.add_action_to_menu_bar("Load Editor", self.load_editor_widget_statement) + # Add an action for changing database connections. + self.add_action_to_menu_bar("Change Database Connections", self.activate_new_connection_dialog) + # Create an action for showing the current history. + self.add_action_to_menu_bar("Show History", self.activate_command_history_dialog) + # Create a sub menu for settings. + settings_menu = QMenu("Settings", self) + # Add the sub menu to the edit menu point. + self.edit_menu.addMenu(settings_menu) + # Add an action for opening a configuration settings dialog to the sub menu for settings. + self.add_action_to_menu_bar("Configuration Settings", self.activate_new_configuration_settings_dialog, + alternate_menu=settings_menu) + # Add an action for opening an editor appearance settings dialog to the sub menu for settings. + self.add_action_to_menu_bar("Editor Appearance Settings", self.activate_new_editor_appearance_dialog, + alternate_menu=settings_menu) + + # Add an action for leaving the application. + self.add_action_to_menu_bar("Exit", self.close_program) + + # Create a new menu bar point: An editor menu. + editor_menu = self.menu_bar.addMenu("Editor") + + # Add the search dialog in the editor to the editor menu. + self.add_action_to_menu_bar("Search", self.search_usage_in_editor, alternate_menu=editor_menu) + + info_menu = self.menu_bar.addMenu("Info") + self.add_action_to_menu_bar("Version", self.show_version_information_dialog, + alternate_menu=info_menu) + + def add_action_to_menu_bar(self, action_name, connected_function, alternate_menu=None): + """ + Add a new action to the menu bar. A name of the new action and the function for connecting is required. First, a + new action is defined. The new action is connected to the new function. The action with the function is added to + the menu bar. If the alternate menu is not None, the alternate menu is used. + """ + + # Define a new action. + new_action = QAction(action_name, self) + # Connect the action with the given function. + new_action.triggered.connect(connected_function) + + # Check for the alternate menu: If the alternate menu is not a QMenu, use the default menu. + if not isinstance(alternate_menu, QMenu): + # Add the point to the menu bar. + self.edit_menu.addAction(new_action) + + # Use a alternate menu. + else: + # Add the action to the alternate menu. + alternate_menu.addAction(new_action) + + def init_tool_bar(self): + """ + Create a tool bar with its actions. + """ + + # Create a tool bar. + self.tool_bar = QToolBar() + # Set the tool bar as not movable. + self.tool_bar.setMovable(False) + # Add the tool bar to the window. + self.addToolBar(self.tool_bar) + + # Add a function for executing a query to the tool bar. + self.add_action_to_tool_bar("Execute Query", "execute.svg", self.execute_query_in_current_editor_widget) + # Add a function for saving the current editor to the tool bar. + self.add_action_to_tool_bar("Save Current Editor", "save.svg", self.save_current_editor_widget_statement) + # Add a function for loading a previous statement in a file to the tool bar. + self.add_action_to_tool_bar("Load File", "load.svg", self.load_editor_widget_statement) + # Add a function for creating a new editor. + self.add_action_to_tool_bar("New Editor", "editor.svg", self.activate_new_editor_tab) + # Add a function for activating a new history command dialog. + self.add_action_to_tool_bar("Show History", "history.svg", self.activate_command_history_dialog) + + def add_action_to_tool_bar(self, action_description, action_icon_file, connected_function): + """ + Add a new action to the tool bar. The required arguments are a description of the action, a file for an icon and + a function for the action. The description is used as definition and tool tip. The file for the icon is + necessary for showing a corresponding icon in the tool bar. The connected function is triggered by the new + action. + """ + + # Create a new action with the given description. The description as name is necessary for potentially missing + # icons, so the action has still a usable definition. + new_action = QAction(action_description, self) + # Set the tool tip with the given description. A tool tip provides more usability, if the icon does not describe + # the function properly for the user. + new_action.setToolTip(action_description) + + # Define the path of the icon. + icon_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", action_icon_file) + + # Check for the existence of the path for an icon. + if os.path.exists(icon_path): + # Make an empty QIcon. + icon = QIcon() + # Add a pixmap to the QIcon with the already checked path. + icon.addPixmap(QPixmap(icon_path)) + # Add the icon to the new action. + new_action.setIcon(icon) + + else: + logging.warning("Icon was not found for the path {} in the tool bar.".format(icon_path)) + + # Connect the new action with the designated function. + new_action.triggered.connect(connected_function) + # Add the new action to the tool bar. + self.tool_bar.addAction(new_action) + + def activate_new_editor_tab(self): + """ + Use the method of the mdi area to generate a new editor tab. + """ + + return self.mdi_area.generate_editor_tab() + + def activate_new_connection_dialog(self, current_selection_identifier=None): + """ + Activate a new connection dialog, so the user can enter data about a new database connection. The connection + parameters are checked and if they are accepted, a signal is used. This signal uses the tree widget (in the + dock widget) to refresh the tree and show the new connection to the user. + There is also a current selection identifier for a pre-selected connection. + """ + + # Create a new connection dialog widget. + self.new_connection_dialog = ConnectionDialogWidget() + self.new_connection_dialog.open_at_start_checkbox.setVisible(False) + + # Check for a identifier of a currently selected connection. + if current_selection_identifier is not None: + # Use the function of the class connection dialog for finding the identifier and selecting it. + self.new_connection_dialog.find_occurrence_in_list_widget_and_select_item(current_selection_identifier) + + # Connect the modified connection dialog to a function of the tree widget for refreshing the tree model. + self.new_connection_dialog.get_modified_connection_parameters.connect(self.change_tree_connection) + # Connect the signal for a changed timeout with the function for setting this new timeout in the current active + # connection. + self.new_connection_dialog.new_timeout_for_connections.connect( + self.set_new_timeout_in_current_active_connection) + + def activate_new_configuration_settings_dialog(self): + """ + Activate a new configuration settings dialog. + """ + + self.configuration_settings_dialog = ConfigurationSettingsDialog() + + def activate_new_editor_appearance_dialog(self): + """ + Activate a new editor appearance dialog. + """ + + self.editor_appearance_dialog = EditorAppearanceSettingsDialog() + + @pyqtSlot(tuple) + def change_tree_connection(self, modified_connection_information): + """ + Use the given, modified connection with its parameters to update the database connection(s) of the tree. + """ + + # Use the function for updating the tree's connection. + self.dock_widget.tree.update_tree_connection(modified_connection_information) + + @pyqtSlot(tuple) + def change_tree_structure(self, modified_connection_information): + """ + Use a function for changing the tree structure and submit the relevant modified connection parameters. + """ + + self.dock_widget.tree.update_tree_structure(modified_connection_information) + + @pyqtSlot(bool) + def set_new_timeout_in_current_active_connection(self): + """ + Find the current editor widget and establish a new connection with the new timeout. + """ + + # Get the current editor widget. + current_widget = self.mdi_area.determine_current_editor_widget() + + # If the current editor widget is not None and the widget is not empty, which means there is no text and no + # connection, proceed. + if current_widget is not None and not current_widget.is_editor_empty(): + # Reestablish the connection. + current_widget.reestablish_connection() + + def execute_query_in_current_editor_widget(self): + """ + Get the current editor widget and execute the query, if the requirements (existing editor widget and valid, open + connection) are fulfilled. + """ + + # Get the current editor widget with a function of the MdiArea. + current_editor_widget = self.mdi_area.determine_current_editor_widget() + + # Check, if a current editor widget exists and proceed. + if current_editor_widget is not None: + # Check for a valid connection with a function of the editor widget. + if current_editor_widget.database_query_executor.is_connection_valid() is True: + # Execute the current query. + current_editor_widget.execute_current_query() + + # Leave the function, because everything for a valid case is done and the following part describes the + # default error case. + return + + # Describe the error for saving in the log and showing to the user. + database_error_message = "The query cannot be executed, because a database is not chosen or is invalid." + # Save the error in the log. + logging.error(database_error_message) + # Show the error to the user as message box. + QMessageBox.critical(self, "Connection Error", database_error_message) + + def save_current_editor_widget_statement(self, save_as=False): + """ + Try to save the current content of the current editor widget. Determine, if a current editor widget exists. If + a current editor widget exists, save the current statement in the file. If the option "save_as" is True, open + always a file dialog. If the option is False, a file dialog is opened in the corner case for a not saved file. + """ + + # Get the current editor widget. + current_editor_widget = self.mdi_area.determine_current_editor_widget() + + # Check, if the current editor widget exists. + if current_editor_widget is not None: + # Check the parameter for save_as. If the parameter is True, the if clause gets to the point for a new + # file dialog. If the result of this file dialog is False, end the function with a return. In this case, + # the process has been aborted. + if save_as is True and current_editor_widget.activate_file_dialog_for_saving_current_statement() is False: + # End the function with a return. + return + + # Save the current statement and text in the query input editor with the function of the editor widget. + current_editor_widget.save_current_statement_in_file() + + # Define an else branch for error handling with a non existing current editor widget. + else: + # Describe the error for saving in the log and showing to the user. + save_error_message = "The statement in the current editor widget cannot be saved, because there is not a " \ + "current editor widget." + + # Save the error in the log. + logging.error(save_error_message) + # Raise a message box with the error to inform the user. + QMessageBox.critical(self, "Saving Error", save_error_message) + + def load_editor_widget_statement(self): + """ + Use a QFileDialog for loading the content of a saved file into a fresh editor widget. If there is an empty + editor widget this widget is used. If there is no such widget, create a new one. + Finding the correct widget and then opening the file dialog is necessary, because a QFileDialog produces bugs + with showing the current/active sub window and its widget after their creation. + """ + + # If an empty editor widget is already existing, get this editor. + empty_editor_widget = self.mdi_area.determine_next_empty_editor_widget() + # Declare the boolean for the necessity of a new editor. This value is False as default. + new_editor_necessary = False + + # Save the current active sub window as previous active sub window, so if the process of choosing a file + # is aborted, this previous sub window is known and the old state can be reproduced. + previous_active_sub_window = self.mdi_area.activeSubWindow() + + # Check for an existing empty editor widget, because the function for finding one can return None for a failed + # search. + if empty_editor_widget is None: + # Create a new editor widget, which is empty as default + empty_editor_widget = self.activate_new_editor_tab() + # Set the boolean for the necessity of a new editor to True, because a new empty editor is created. + new_editor_necessary = True + + # If there is an existing editor widget, activate the corresponding sub window. + else: + # Set the active sub window to the parent of the empty editor widget. This is the corresponding sub window + # in the MdiArea. + self.mdi_area.setActiveSubWindow(empty_editor_widget.parent()) + + # If the process of loading the file in the editor failed, the return value is False. + if empty_editor_widget.load_statement_out_of_file() is False: + # If a new editor was necessary, proceed with the closing event of the corresponding sub window. + if new_editor_necessary: + # Close the sub window of the new editor widget. + self.mdi_area.closeActiveSubWindow() + + # If a new editor was not necessary, use the previous active sub window. + else: + # Use the previous active sub window as new active sub window, so the state before the file process is + # reproduced. + self.mdi_area.setActiveSubWindow(previous_active_sub_window) + + def load_editor_with_connection_and_query(self, database_connection_parameter, drop_statement): + """ + Use the given database connection parameters for creating a new editor widget with the given connection. If + there is an editor widget without a statement and the appropriate connection, use it. Set the given statement + as text. + """ + + # Determine the editor widget for the parameters. + editor_widget = self.mdi_area.determine_empty_editor_widget_with_connection(database_connection_parameter) + # Set the text in the editor. The text is the given drop statement. + editor_widget.query_input_editor.setText(drop_statement) + + def load_empty_editor_with_command(self, command): + """ + Get a command and set this command as text of an empty editor. + """ + + # If an empty editor widget is already existing, get this editor. + empty_editor_widget = self.mdi_area.determine_next_empty_editor_widget() + + # Check for an existing empty editor widget, because the function for finding one can return None for a failed + # search. + if empty_editor_widget is None: + # Create a new editor widget, which is empty as default + empty_editor_widget = self.activate_new_editor_tab() + + # Set the text to the query input editor of the editor widget. + empty_editor_widget.query_input_editor.setText(command) + + @pyqtSlot(str) + def show_status_bar_message(self, message): + """ + Set the current text of the status bar. + """ + + self.statusBar().showMessage(message) + + def search_usage_in_editor(self): + """ + Get the current editor and if a current editor exists, open its search dialog. + """ + + # Get the current editor with the mdi area. + current_editor = self.mdi_area.determine_current_editor_widget() + + # Proceed, if a current editor exists. + if current_editor is not None: + # Open the search dialog. + current_editor.open_search_dialog() + + def show_version_information_dialog(self): + """ + Activate a dialog for showing the current information of the application. + """ + + self.version_information_dialog = VersionInformationDialog() + + def activate_command_history_dialog(self): + """ + Activate a command history widget. + """ + + self.command_history_dialog = CommandHistoryDialog() + # Connect the signal for getting the command with a double click in the history with the function for loading an + # empty editor with this command. + self.command_history_dialog.get_double_click_command.connect(self.load_empty_editor_with_command) + + def close_program(self): + """ + Define a wrapper function for closing the application. + """ + + self.close() diff --git a/pygadmin/widgets/mdi_area.py b/pygadmin/widgets/mdi_area.py new file mode 100644 index 0000000..c484114 --- /dev/null +++ b/pygadmin/widgets/mdi_area.py @@ -0,0 +1,279 @@ +import logging +import re + +from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtWidgets import QMdiArea +from PyQt5.QtCore import pyqtSlot, pyqtSignal + +from pygadmin.widgets.editor import EditorWidget +from pygadmin.connectionfactory import global_connection_factory + + +class MdiArea(QMdiArea): + """ + Create a class for a customized MdiArea as program intern window management widget for all basic widgets (mainly + the editor widget) except the tree widget. + """ + + # Use a signal for the changed of the current sub window. + current_sub_window_change = pyqtSignal(object) + + def __init__(self): + """ + Make sub function for initializing the widget and connecting a signal. + """ + + super().__init__() + self.init_ui() + # Connect the signal for an activated sub window with a customized function. + self.subWindowActivated.connect(self.on_sub_window_change) + + def init_ui(self): + """ + Design the user interface and its components. + """ + + # Set the tabs movable, so they can be moved in the MdiArea. + self.setTabsMovable(True) + # Set the tabs closeable, so they can be removed. + self.setTabsClosable(True) + # Set the view mode to tabs, so tabs are used instead of windows, which is more usable. + self.setViewMode(self.TabbedView) + + # Create an empty icon. + icon = QIcon(QPixmap(0, 0)) + # Use the empty icon as window icon, so the pygadmin logo is not in the window title bar of every editor widget. + self.setWindowIcon(icon) + + def generate_editor_tab(self): + """ + Generate a new editor widget as sub window of the MdiArea and connect a signal for a change table/view for an + existing parent of the MdiArea, for example a QMainWindow. Use a parameter, so a previous statement, saved in + a file, can be loaded with a function of the editor widget. + """ + + # Create the editor widget. + editor_widget_to_generate = EditorWidget() + + # Add the widget to the MdiArea as a sub window. + self.addSubWindow(editor_widget_to_generate) + + # Try to use the parent of the MdiArea with a specified signal. + try: + # Connect the signal for the structural change to the slot of the parent of the MdiArea, which should + # receive the signal for further usage like informing other components. + editor_widget_to_generate.structural_change_in_view_table.connect(self.parent().change_tree_structure) + # Connect the function for changing the status message of the main window with the function for changing a + # new status bar message. + editor_widget_to_generate.change_in_status_message.connect(self.parent().show_status_bar_message) + + # If something went wrong, save a message in the log. + except Exception as parent_error: + logging.warning("The new generated editor widget, which was placed on the MdiArea, cannot find a parent " + "with the correct signal, resulting in the following error: {}".format(parent_error), + exc_info=True) + + editor_widget_to_generate.showMaximized() + + return editor_widget_to_generate + + @pyqtSlot(dict) + def change_current_sub_window_and_connection_parameters(self, database_connection_parameters): + """ + Get the current activated sub window and change the database connection parameters of the editor widget, which + is currently activated. As a result, its database connection is changed too. + """ + + # Get the current widget for which the slot is used. + current_editor_widget = self.determine_current_editor_widget() + + # Check, if the result is not None. + if current_editor_widget is not None: + # Change the connection based on its parameters of the editor widget. + current_editor_widget.set_connection_based_on_parameters(database_connection_parameters) + + def determine_current_editor_widget(self): + """ + Determine the current, active or only given editor widget, which is shown in a sub window. The possible + scenarios contain a single widget without recognition by currentSubWindow() as function of the MdiArea and the + recognition of a current sub window and its widget by the function. The check for an editor widget is required. + If there is None, None is returned. + """ + + # If currentSubWindow returns None, there could still be a widget, so there is also a check for the list of all + # sub windows. If this list is not empty, continue. + if self.currentSubWindow() is None and self.subWindowList: + # Check all sub windows in the list for their widget. If their widget is an editor widget, it is stored in a + # list. + editor_widget_list = [sub_window.widget() for sub_window in self.subWindowList() + if isinstance(sub_window.widget(), EditorWidget)] + + # Check for content in the list and proceed if the list is not empty. + if editor_widget_list: + # Get the first item of the list as editor widget. + first_editor_widget = editor_widget_list[0] + + return first_editor_widget + + # Check if the current sub window is not None. If it is None, all sub windows are closed (or non existent) and a + # change of connection parameters does not need to be transmitted except in the case checked above. + elif self.currentSubWindow() is not None: + # Get the widget of the sub window, which is currently active. + current_widget = self.currentSubWindow().widget() + + # If the current widget is an EditorWidget, the parameters are committed, because they are only relevant + # to an editor field and not to every widget. + if isinstance(current_widget, EditorWidget): + return current_widget + + # If there is not a widget, just return None. + else: + return None + + def determine_empty_editor_widget_with_connection(self, database_connection_parameter): + """ + Find the editor widget with the current connection and without a current query/text. If such an editor does not + exist, create one and return it. + """ + + # Get the current editor widget. + current_editor_widget = self.determine_current_editor_widget() + + # If there is a current widget, proceed. + if current_editor_widget is not None: + # Get the current database connection parameters of the current editor widget. + database_connection_parameter_of_current_widget = \ + global_connection_factory.get_database_connection_parameters( + current_editor_widget.current_database_connection) + + # Check, if the parameters of current editor widget are the given parameters and if the current editor does + # not contain any text. + if database_connection_parameter_of_current_widget == database_connection_parameter and \ + current_editor_widget.query_input_editor.text() == "": + # Return the current editor widget for a success. + return current_editor_widget + + # Proceed, if the current editor widget is not a match or is None. + if self.subWindowList(): + # Get all editor widgets. + editor_widget_list = [sub_window.widget() for sub_window in self.subWindowList() + if isinstance(sub_window.widget(), EditorWidget)] + + # Check every editor widget. + for editor_widget in editor_widget_list: + # Get the database connection parameter of the widget. + database_connection_parameter_of_widget = global_connection_factory.get_database_connection_parameters( + editor_widget.current_database_connection) + + # Check the database connection parameters for equality and the editor for an empty text field. + if database_connection_parameter_of_widget == database_connection_parameter and \ + editor_widget.query_input_editor.text() == "": + # Return the widget for a success. + return editor_widget + + # If the steps before fail, generate a new editor widget. + new_editor_widget = self.generate_editor_tab() + # Set the connection in the new widget based on the given database connection parameters. + new_editor_widget.set_connection_based_on_parameters(database_connection_parameter) + + return new_editor_widget + + def on_sub_window_change(self): + """ + Describe the behavior of the MdiArea, if the active sub window is changed. It is a more specific wrapper for the + already existing signal of QMdiArea called subWindowActivated. The idea is to inform the tree, containing all + database connections, of a change of the database connection, so the original tree layout for this EditorWidget + can be restored. + """ + + # Check for a current active sub window, because only existing sub windows can contain a widget, which is one + # relevant point of this function. + if self.currentSubWindow() is not None: + # Get the widget of the sub window. + current_active_sub_window = self.currentSubWindow().widget() + + # Check for an EditorWidget, because only EditorWidgets need to know about a requested change of connection + # parameters. + if isinstance(current_active_sub_window, EditorWidget): + # Get the current database connection, which is a class-wide object in the widget. + current_editor_connection = current_active_sub_window.current_database_connection + + # If the connection exists and is not closed, proceed. + if current_editor_connection and current_editor_connection.closed == 0: + # Get the parameters of the database connection, because the receiving object must be handled with + # database connection parameters. + database_parameter_dictionary = global_connection_factory.get_database_connection_parameters( + current_editor_connection) + + # Emit the change with the dictionary via slots and signals. + self.current_sub_window_change.emit(database_parameter_dictionary) + + # This else branch is used for two corner cases: The database connection failed or the database + # connection is closed. So the connection identifier of the current sub window is used for the transfer + # of connection parameters. + elif (current_editor_connection and current_editor_connection.closed == 1) or \ + current_editor_connection is False: + # Check for a given identifier. The identifier is initialized as None, so there must have been a + # change. + if current_active_sub_window.connection_identifier is not None: + # Split the connection identifier. + parameter_list = [parameter.strip() for parameter in re.split( + "[@:/]", current_active_sub_window.connection_identifier)] + + # Make a dictionary out of the list, which split the identifier. + database_parameter_dictionary = { + "user": parameter_list[0], + "host": parameter_list[1], + # Cast the port to an integer for preventing weird behavior. + "port": int(parameter_list[2]), + "database": parameter_list[3] + } + + # Emit the change with the dictionary of a failed connection. + self.current_sub_window_change.emit(database_parameter_dictionary) + + # If the connection does not exists, return None, so the selection is cleared. + else: + self.current_sub_window_change.emit(None) + + # The else branch is relevant for a closed sub window. + else: + self.current_sub_window_change.emit(None) + + def determine_next_empty_editor_widget(self): + """ + Determine the next empty editor widget based on the current given widget and the existing editor widgets. First, + check the current widget for an editor widget. If the widget of the current sub window is an editor widget, + check this widget for emptiness. An empty widget will be returned. If the current widget is not an editor widget + or not empty, check the list of all sub windows for their widget. If this widget is an editor widget and empty, + return it. If an empty widget can not be found, return None. + """ + + # Check for a current existing sub window. + if self.currentSubWindow() is not None: + # Check the widget of the current sub window for an editor widget. + if isinstance(self.currentSubWindow().widget(), EditorWidget): + # Get the widget of the current sub window. + next_editor_widget_candidate = self.currentSubWindow().widget() + + # Check for an empty editor with an instance check and the function of the editor for its own emptiness. + if isinstance(next_editor_widget_candidate, EditorWidget) \ + and next_editor_widget_candidate.is_editor_empty() is True: + # Return an empty editor. + return next_editor_widget_candidate + + # Get a list of all editor widgets with a list comprehension: Check all sub windows in the list of all sub + # windows for their widget and the instance of their widget. + editor_widget_list = [sub_window.widget() for sub_window in self.subWindowList() + if isinstance(sub_window.widget(), EditorWidget)] + + # Check every editor in the editor widget list. + for editor_widget in editor_widget_list: + # Check the editor for emptiness. + if editor_widget.is_editor_empty() is True: + # If the editor is empty, return it. This statement causes the function to end: The first match is + # returned. + return editor_widget + + # None will be returned, if the search for a currently existing empty editor widget is unsuccessful. + return None diff --git a/pygadmin/widgets/node_create_information.py b/pygadmin/widgets/node_create_information.py new file mode 100644 index 0000000..a1a0750 --- /dev/null +++ b/pygadmin/widgets/node_create_information.py @@ -0,0 +1,125 @@ +import logging +import os + +from PyQt5.QtWidgets import QDialog, QGridLayout, QLabel +from PyQt5.QtCore import Qt + +from pygadmin.models.treemodel import DatabaseNode, TableNode, ViewNode +from pygadmin.database_dumper import DatabaseDumper +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class NodeCreateInformationDialog(QDialog): + """ + Create a dialog for showing the information about a database node, in this case the create statement. + """ + + def __init__(self, selected_node): + super().__init__() + self.setModal(False) + # Add the pygadmin icon as window icon. + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + + # Check for the correct instance of the given node. + if isinstance(selected_node, DatabaseNode) or isinstance(selected_node, TableNode) or \ + isinstance(selected_node, ViewNode): + # Use the given node as base and root for further information as attribute. + self.selected_node = selected_node + # Initialize the user interface. + self.init_ui() + # Initialize the grid layout. + self.init_grid() + + # Activate the else branch for a node with the wrong type as input parameter. + else: + # Save an error in the log. + logging.error("The given node {} is not a Database/Table/View Node and as a consequence, the specific " + "actions for a such a node like showing the definition and the information are not " + "possible.".format(selected_node)) + + # Initialize a UI for the error case. + self.init_error_ui() + + def init_ui(self): + """ + Initialize the user interface. + """ + + # Get the create statement. + create_statement = self.get_node_create_statement() + # Set the create statement in a label. + self.create_statement_label = QLabel(create_statement) + # Enable the multi line mode. + self.create_statement_label.setWordWrap(True) + # Make the text of the label selectable by the mouse. + self.create_statement_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + self.setMaximumSize(720, 300) + self.showMaximized() + self.setWindowTitle("Create Statement of {}".format(self.selected_node.name)) + self.show() + + def init_grid(self): + """ + Initialize the grid layout. + """ + + grid_layout = QGridLayout(self) + grid_layout.addWidget(self.create_statement_label, 0, 0) + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def init_error_ui(self): + """ + Use a small UI for the error case of a wrong node type as input parameter. + """ + + # Get the layout as grid layout. + grid_layout = QGridLayout(self) + # Add a label with an error. + grid_layout.addWidget(QLabel("The given node is not a database node, so a definition cannot be shown."), 0, 0) + self.setLayout(grid_layout) + self.setMaximumSize(10, 100) + self.showMaximized() + # Set the title to an error title. + self.setWindowTitle("Node Input Error") + self.show() + + def get_node_create_statement(self): + """ + Get the create statement of the given database with pg_dump and the class around the subprocess. + """ + + # Get the connection parameters of the database node. + connection_parameters = self.selected_node.database_connection_parameters + # Create a class for the dump with the required connection parameters and information about the node. + database_dump = DatabaseDumper(connection_parameters["user"], connection_parameters["database"], + connection_parameters["host"], connection_parameters["port"], + self.selected_node.get_node_type(), self.selected_node.name) + + # Get the create statement with the relevant create/alter part. + dump_result = database_dump.dump_database_and_clean_result() + + # Check for a valid result of the database dump. If the result list is empty, something went wrong. + if not dump_result: + # Define an error for the user. + error_string = "The dump failed. Please check the log for further information or check the pg_dump " \ + "executable in the configuration settings." + + # Return the error string, so instead of a valid result, the error is shown. + return error_string + + # Make a string out of the list. + result_string = "".join(dump_result) + + # Define a list of words for splitting the result string. Before these words, there will be a newline for better + # reading. + split_list = ["WITH", "ENCODING", "LC_COLLATE", "LC_CTYPE", "LC_TYPE", "ALTER"] + + # Check every string in the defined split list. + for split_string in split_list: + # If a word for splitting occurs, create a newline before the word. + result_string = result_string.replace(split_string, os.linesep + split_string) + + return result_string diff --git a/pygadmin/widgets/permission_information.py b/pygadmin/widgets/permission_information.py new file mode 100644 index 0000000..f14900d --- /dev/null +++ b/pygadmin/widgets/permission_information.py @@ -0,0 +1,236 @@ +import logging + +from PyQt5.QtWidgets import QDialog, QGridLayout, QLabel, QTableView, QMessageBox + +from pygadmin.connectionfactory import global_connection_factory +from pygadmin.database_query_executor import DatabaseQueryExecutor +from pygadmin.models.tablemodel import TableModel +from pygadmin.models.treemodel import TableNode, ViewNode, DatabaseNode + + +class PermissionInformationDialog(QDialog): + """ + Create a dialog for showing the information about the permissions on a database, table or view. + """ + + def __init__(self, selected_node): + """ + Get a selected node as input parameter, so the information is based on the given node. After a type check, + initialize the relevant attributes with initializing the UI and the layout. + """ + + super().__init__() + self.setModal(True) + + # Check for the correct instance of the given node. Table, View and Database nodes are valid, because the + # required information exists for these nodes. + if isinstance(selected_node, TableNode) or isinstance(selected_node, ViewNode) \ + or isinstance(selected_node, DatabaseNode): + # Set the given node as attribute for easier access. + self.selected_node = selected_node + # Initialize the UI and the grid layout. + self.init_ui() + self.init_grid() + + # Save and show an error in the else branch, because the node has the wrong instance. + else: + # Save an error in the log. + logging.error("The given node {} is not a Table, View or Database node. As a consequence, the specific " + "actions for checking permissions are not available.".format(selected_node)) + + # Initialize a UI for the error case. + self.init_error_ui() + + def init_ui(self): + """ + Initialize the user interface with the relevant components for showing and processing the information. + """ + + # Create a label for showing the super users. + self.super_user_label = QLabel() + # Create a table model for showing the result of a query in a table. + self.table_model = TableModel([]) + # Create a table view for the model for showing the data in the GUI. + self.table_view = QTableView() + self.table_view.setModel(self.table_model) + + # Get the database connection parameters of the selected node for usage in the database query executor later. + database_connection_parameters = self.selected_node.database_connection_parameters + # Get the database connection related to the parameters, because the database query executor requires a database + # connection and not connection parameters. + self.database_connection = global_connection_factory.get_database_connection( + database_connection_parameters["host"], + database_connection_parameters["user"], + database_connection_parameters["database"], + database_connection_parameters["port"], + database_connection_parameters["timeout"]) + + # Create a database query executor for executing the relevant queries for the requested permission information. + self.database_query_executor = DatabaseQueryExecutor() + # Connect the signal for a successful query and its data to the function for processing the result data, so the + # permission information is shown in the GUI at the end. + self.database_query_executor.result_data.connect(self.process_result_data) + # Connect the error signal to the function for processing the error, so the user is informed about it. + self.database_query_executor.error.connect(self.process_error) + + # Define two booleans for a query check: If the query for the required parameter is executed, set the check to + # True. + self.super_user_check = False + self.function_table_check = False + + # Get the super users, which triggers also the query for getting the function data/table data. + self.get_super_users() + + # Adjust the size of the dialog. + self.setMaximumSize(720, 300) + self.showMaximized() + self.setWindowTitle("Permissions for {}".format(self.selected_node.name)) + self.show() + + def init_grid(self): + """ + Initialize the grid layout. + """ + + grid_layout = QGridLayout(self) + grid_layout.addWidget(self.super_user_label, 0, 0) + grid_layout.addWidget(self.table_view, 1, 0) + + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def init_error_ui(self): + """ + Use a small UI for the error case of a wrong node instance as input parameter. + """ + + # Get the layout as grid layout. + grid_layout = QGridLayout(self) + # Add a label with an error. + grid_layout.addWidget(QLabel("The given node is not a Table, View or Database node, so a definition cannot " + "be shown."), 0, 0) + self.setLayout(grid_layout) + self.setMaximumSize(10, 100) + self.showMaximized() + # Set the title to an error title. + self.setWindowTitle("Node Input Error") + self.show() + + def process_result_data(self, data_list): + """ + Process the result data based on the executed queries and the instance of the given node. + """ + + # If the super user check is True, the query has been executed. If the function table check is False, the + # related query needs to be executed. + if self.super_user_check is True and self.function_table_check is False: + # Update the super user information in the GUI. + self.update_super_user_information(data_list) + # Check for a database node: For databases, there are functions given. + if isinstance(self.selected_node, DatabaseNode): + self.get_function_permissions() + + # For a Table or a View node, there are special permissions on the table or view. + else: + self.get_table_view_permissions() + + # In this case, the function for getting the function or table permissions is executed and has now a data list + # as result. + else: + self.update_information_table(data_list) + + def process_error(self, error): + """ + Process the given error of a database execution fail with a message in the log and a message in the GUI. + """ + + logging.error("During the query execution, an error occurred: {}".format(error)) + QMessageBox.critical(self, "Information Query Error", "The query for getting the information could not" + " be executed with the error {}".format(error)) + + def get_table_view_permissions(self): + """ + Get the permissions for a table based on a query and with help of the database query executor. + """ + + # Define the query. + database_query = "SELECT * FROM information_schema.role_table_grants WHERE table_name=%s" + + # Use the name of the table or view as parameter, so information about this table or view can be found. + database_query_parameter = [self.selected_node.name] + + # Use the database connection for the executor. + self.database_query_executor.database_connection = self.database_connection + # Use the query and the parameter. + self.database_query_executor.database_query = database_query + self.database_query_executor.database_query_parameter = database_query_parameter + # A function for checking permissions on a function or table is now executed, so this value is True now. + self.function_table_check = True + # Execute the query. + self.database_query_executor.submit_and_execute_query() + + def get_function_permissions(self): + """ + Get the permissions for a table based on a query and with help of the database query executor. + """ + + # Define the required query. + database_query = "SELECT * FROM information_schema.role_routine_grants WHERE specific_schema='public'" + + # Set the connection and the query. + self.database_query_executor.database_connection = self.database_connection + self.database_query_executor.database_query = database_query + # A function for checking permissions on a function or table is now executed, so this value is True now. + self.function_table_check = True + self.database_query_executor.submit_and_execute_query() + + def get_super_users(self): + """ + Get the super users based on a query and with help of the database query executor. + """ + + # Define the query. + database_query = "SELECT usename FROM pg_user WHERE usesuper='True'" + # Use the relevant values for the query executor. + self.database_query_executor.database_connection = self.database_connection + self.database_query_executor.database_query = database_query + # Set the boolean to True, because now, the super users are loaded. + self.super_user_check = True + self.database_query_executor.submit_and_execute_query() + + def update_super_user_information(self, super_user_result_list): + """ + Update the super user information in the GUI based on the result list after the query execution. + """ + + # If the list is longer than 1, there is a usable result. + if len(super_user_result_list) > 1: + # Define a list for super users, because only there names are necessary. + super_user_list = [] + # Get every super user in a query. + for user_number in range(len(super_user_result_list)-1): + # Get the first parameter of the tuple with the result. This is the super user. + super_user_list.append(super_user_result_list[user_number+1][0]) + + # Define a text for the GUI. + self.super_user_label.setText("The following super users were found: ") + + # Add every super user to the label. + for user in super_user_list: + self.super_user_label.setText("{} {}".format(self.super_user_label.text(), user)) + + # Show an information, if there is no super user. + else: + self.super_user_label.setText("No super user was found.") + + def update_information_table(self, data_list): + """ + Update the table with the new data. + """ + + self.table_model.refresh_data_list(data_list) + self.table_view.resizeColumnsToContents() + + + + diff --git a/pygadmin/widgets/search_replace_parent.py b/pygadmin/widgets/search_replace_parent.py new file mode 100644 index 0000000..2200a01 --- /dev/null +++ b/pygadmin/widgets/search_replace_parent.py @@ -0,0 +1,63 @@ +import abc + + +class SearchReplaceParent(abc.ABC): + """ + Create an interface with the methods, which are necessary for a widget as parent of the search replace widget. + """ + + @abc.abstractmethod + def deactivate_search_next_and_replace_buttons_and_deselect(self): + """ + Create a function for deactivating the search and replace buttons of the widget. + """ + + pass + + @abc.abstractmethod + def search_and_select_sub_string(self): + """ + Create a method for searching and selection a sub string. + """ + + pass + + @abc.abstractmethod + def search_and_select_next_sub_string(self): + """ + Create a method for searching and selecting the following/next sub string. + """ + + pass + + @abc.abstractmethod + def close_search_replace_widget(self): + """ + Create a method for closing the search/replace widget. + """ + + pass + + @abc.abstractmethod + def check_for_replace_enabling(self): + """ + Create a method for checking the enabling of the replace buttons. + """ + + pass + + @abc.abstractmethod + def replace_current_selection(self): + """ + Create a method for replacing the current selection. + """ + + pass + + @abc.abstractmethod + def replace_all_sub_string_matches(self): + """ + Create a method for replacing all sub string matches. + """ + + pass diff --git a/pygadmin/widgets/search_replace_widget.py b/pygadmin/widgets/search_replace_widget.py new file mode 100644 index 0000000..14b85de --- /dev/null +++ b/pygadmin/widgets/search_replace_widget.py @@ -0,0 +1,207 @@ +from PyQt5.QtWidgets import QWidget, QLineEdit, QPushButton, QGridLayout + +from pygadmin.widgets.search_replace_parent import SearchReplaceParent + + +class SearchReplaceWidget(QWidget): + """ + Create an own widget for the search and replace dialog in the editor widget. + """ + + def __init__(self, parent): + super().__init__() + + # Check for the correct instance of the parent. + if isinstance(parent, SearchReplaceParent) and isinstance(parent, QWidget): + # Use the given parent, which is normally an editor widget. + self.setParent(parent) + self.init_ui() + self.init_grid() + + def init_ui(self): + """ + Create the user interface. + """ + + # Create a dictionary for saving all search items. + self.search_items = {} + + # Create a line edit as search field. + search_line_edit = QLineEdit() + # Deactivate the button for searching the next match for a changed text in the line edit, because the function + # for searching the next item is based on the first search. + search_line_edit.textChanged.connect(self.parent().deactivate_search_next_and_replace_buttons_and_deselect) + self.search_items["search_line_edit"] = search_line_edit + + # Create a button for search. + search_button = QPushButton("Search") + # Search the (sub) string in the search line edit with clicking on the button. + search_button.clicked.connect(self.parent().search_and_select_sub_string) + self.search_items["search_button"] = search_button + + # Define a button for the next search result. + search_next_button = QPushButton("Next") + # Search the next (sub) string after clicking on the button. + search_next_button.clicked.connect(self.parent().search_and_select_next_sub_string) + self.search_items["search_next_button"] = search_next_button + + # Create a button for closing the widget. + cancel_button = QPushButton("Cancel") + # Close the dialog with setting it invisible. + cancel_button.clicked.connect(self.parent().close_search_replace_widget) + self.search_items["cancel_button"] = cancel_button + + # Create a dictionary for saving all replace items. + self.replace_items = {} + + # Create line edit for the replace text. + replace_line_edit = QLineEdit() + # Connect the text with the function for checking if a replace should be enabled. + replace_line_edit.textChanged.connect(self.parent().check_for_replace_enabling) + self.replace_items["replace_line_edit"] = replace_line_edit + + # Create a button for replacing. + replace_button = QPushButton("Replace") + # Connect the button to the function for replacing the current searched selection. + replace_button.clicked.connect(self.parent().replace_current_selection) + self.replace_items["replace_button"] = replace_button + + # Create a button for replacing all occurrences of a sub string. + replace_all_button = QPushButton("Replace All") + # Connect the button to the function for replacing all sub string matches. + replace_all_button.clicked.connect(self.parent().replace_all_sub_string_matches) + self.replace_items["replace_all_button"] = replace_all_button + + # Hide the components for replacing, because the standard dialog is a plain, simple search dialog. + self.hide_replace_components() + + self.show() + + def init_grid(self): + """ + Create the grid layout. + """ + + # Define the layout. + grid_layout = QGridLayout(self) + + # Define a count for placing the items in a row. + count = 0 + # Place every item of the search items in a row. + for item in self.search_items.values(): + grid_layout.addWidget(item, 0, count) + count += 1 + + # Set the count back to 0 for the next items. + count = 0 + # Place every item of the replace items in a row below the search items. + for item in self.replace_items.values(): + grid_layout.addWidget(item, 1, count) + count += 1 + + grid_layout.setSpacing(10) + + self.setLayout(grid_layout) + + def hide_replace_components(self): + """ + Hide the replace components with setting them to invisible. + """ + + # Use the dictionary with the replace items for setting every item to invisible. + for item in self.replace_items.values(): + item.setVisible(False) + + def show_replace_components(self): + """ + Show the replace components with setting them visible. + """ + + # Set every item in the replace dictionary to visible. + for item in self.replace_items.values(): + item.setVisible(True) + + def deactivate_replace_buttons(self): + """ + Deactivate both replace buttons. + """ + + self.replace_items["replace_button"].setEnabled(False) + self.replace_items["replace_all_button"].setEnabled(False) + + def activate_replace_buttons(self): + """ + Activate both replace buttons. + """ + + self.replace_items["replace_button"].setEnabled(True) + self.replace_items["replace_all_button"].setEnabled(True) + + def activate_search_next_button(self): + """ + Activate the search next button, so a jump to the next search result with the selection is possible. + """ + + self.search_items["search_next_button"].setEnabled(True) + + def deactivate_search_next_button(self): + """ + Deactivate the search next button. + """ + + self.search_items["search_next_button"].setEnabled(False) + + def activate_replace_button(self): + """ + Activate the replace button. + """ + + self.replace_items["replace_button"].setEnabled(True) + + def deactivate_replace_all_button(self): + """ + Deactivate the replace all button. + """ + + self.replace_items["replace_all_button"].setEnabled(False) + + def set_widget_visible(self, replace=False): + """ + Set the widget visible. Replace is as default False, so there is only a plain and simple standard search dialog. + If replace is not False, the replace components are shown. + """ + + # Set the widget visible. + self.setVisible(True) + + # Check for the replace parameter and if this parameter is not False, show also the replace components. + if replace: + self.show_replace_components() + + # Hide the replace components, so there is only the search dialog. + else: + self.hide_replace_components() + + def set_search_text(self, search_text): + """ + Set a given text in the search line edit. + """ + + self.search_items["search_line_edit"].setText(search_text) + + def get_replace_text(self): + """ + Return the text of the replace line edit. + """ + + return self.replace_items["replace_line_edit"].text() + + def get_search_text(self): + """ + Return the text of the search line edit. + """ + + return self.search_items["search_line_edit"].text() + + def set_widget_invisible(self): + self.setVisible(False) diff --git a/pygadmin/widgets/start_progress_dialog.py b/pygadmin/widgets/start_progress_dialog.py new file mode 100644 index 0000000..e3f1162 --- /dev/null +++ b/pygadmin/widgets/start_progress_dialog.py @@ -0,0 +1,110 @@ +from PyQt5.QtWidgets import QProgressBar, QGridLayout, QLabel, QDialog +from PyQt5.QtCore import QBasicTimer, pyqtSlot + +from pygadmin.connectionstore import global_connection_store +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class StartProgressDialog(QDialog): + """ + Create a dialog for the start of the application. The dialog shows the current progress of the loaded server nodes + in the tree. + """ + + def __init__(self): + """ + Initialize the user interface and the grid layout. + """ + + super().__init__() + # Set the dialog modal, so until the start process is not done, the dialog is the primary widget. + self.setModal(True) + # Add the pygadmin icon as window icon. + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + self.init_ui() + self.init_grid() + + def init_ui(self): + """ + Initialize the user interface. + """ + + # Set the size of the dialog. + self.resize(400, 100) + # Create a progress bar. + self.progress_bar = QProgressBar(self) + # Set the minimum size of the progress bar. + self.progress_bar.setMinimumSize(300, 20) + # Add a description label for describing the current process. + self.description_label = QLabel("Pygadmin is currently starting and is loading the server nodes.") + + # Create a timer as required component for the progress bar. + self.timer = QBasicTimer() + + # Set the current step to 0, so the bar shows 0% at the beginning. + self.step = 0 + + # Start the progress bar. + self.start_progress_bar() + + self.setWindowTitle("Starting pygadmin...") + self.show() + + def init_grid(self): + """ + Initialize the grid layout. + """ + + grid_layout = QGridLayout(self) + grid_layout.addWidget(self.description_label, 0, 0) + grid_layout.addWidget(self.progress_bar, 1, 0) + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def start_progress_bar(self): + """ + Start the progress bar: Get the initial parameters for choosing a step size, based on the number of connections + in the connection store, and start the timer. + """ + + # Load all current connection parameters in the yaml file. + global_connection_store.get_connection_parameters_from_yaml_file() + # Get the number of all current connection parameters. + self.connection_number = global_connection_store.get_number_of_connection_parameters() + + # Set the maximum of the progress bar as current connection number. + self.progress_bar.setMaximum(self.connection_number) + + # Set the step size as 1/n-th of the connection number, which is realized by setting the step size to 1, while + # the maximum is the connection number. + self.step_size = 1 + + # Start the timer. + self.timer.start(100, self) + + def timerEvent(self, event): + """ + Implement the method for a timer event: If the timer reaches 100% (or above), stop the timer and abort the + dialog. + """ + + # Check for 100% or more. + if self.step >= self.connection_number: + # Stop the timer. + self.timer.stop() + # Close the dialog, so the application can start smoothly. + self.close() + + @pyqtSlot(bool) + def get_new_step_size(self): + """ + Increment the step size by signal. + """ + + # Add a new 1/n-th to the current float step, while the step size is actually 1, but the maximum is the + # connection number + self.step += self.step_size + # Set the value as integer to the progress bar. + self.progress_bar.setValue(self.step) + diff --git a/pygadmin/widgets/table_edit.py b/pygadmin/widgets/table_edit.py new file mode 100644 index 0000000..994b04a --- /dev/null +++ b/pygadmin/widgets/table_edit.py @@ -0,0 +1,393 @@ +import copy +import logging + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QGridLayout, QLabel, QTableView, QMessageBox, QLineEdit, QPushButton, QDialog, QCheckBox + +from pygadmin.connectionfactory import global_connection_factory +from pygadmin.database_query_executor import DatabaseQueryExecutor +from pygadmin.models.tablemodel import TableModel +from pygadmin.models.treemodel import TableNode +from pygadmin.configurator import global_app_configurator + + +class EditTableModel(TableModel): + """ + Create a sub class of the table model, so editing the cells is possible. + """ + + def __init__(self, data_list): + """ + Initialize the model with a data list. + """ + + super().__init__(data_list) + + def flags(self, index): + """ + Define flags for enabling and editing the items in the cells. + """ + + if not index.isValid(): + return Qt.ItemIsEnabled + + return Qt.ItemIsEnabled | Qt.ItemIsEditable + + def setData(self, index, value, role=Qt.DisplayRole): + """ + Get new data for setting in the table model, defined by the index for setting the new value, the value itself + and the display role. + """ + + # Get the row and the column of the relevant item based on the index. + row = index.row() + column = index.column() + + # Get the relevant row as list for replacing an item. + relevant_row = list(self.data_list[row + 1]) + # Set the value to the cell in the relevant row, defined by the column. + relevant_row[column] = value + # Set the relevant row as tuple in the data list. + self.data_list[row + 1] = tuple(relevant_row) + # Append the row and the column to the list of changed row and column tuples, so the marker for a change in the + # cell is used. + self.change_list.append((row, column)) + + # Emit the signal for a change in the data. The signal must be emitted explicitly for a reimplementation of the + # function setData. + self.dataChanged.emit(index, index, [0]) + + return True + + +class TableEditDialog(QDialog): + """ + Define a widget for editing the current table and update single cells. + """ + + def __init__(self, table_node): + super().__init__() + + # Check for the correct instance of the given node. This should be a Table Node, because the whole widget is + # about editing a table. + if isinstance(table_node, TableNode): + self.selected_table_node = table_node + self.init_ui() + self.init_grid() + + # If the given node has the wrong instance, show the user interface for the error case. + else: + self.init_error_ui() + + def init_ui(self): + """ + Initialize the user interface and the relevant components. + """ + + self.table_model = EditTableModel([]) + self.table_view = QTableView() + self.table_view.setModel(self.table_model) + self.table_model.dataChanged.connect(self.process_table_data_change) + + # Get the connection parameters based on the table node. + connection_parameters = self.selected_table_node.database_connection_parameters + + # Get the database connection based on the parameters of the table node. + self.database_connection = global_connection_factory.get_database_connection(connection_parameters["host"], + connection_parameters["user"], + connection_parameters["database"], + connection_parameters["port"], + connection_parameters["timeout"]) + # Define a query executor. + self.database_query_executor = DatabaseQueryExecutor() + # Connect the resulting data list with a function for refreshing the data in the table model. + self.database_query_executor.result_data.connect(self.refresh_data) + # Connect the error with a function for processing an error. + self.database_query_executor.error.connect(self.process_error) + + # Normally, queries with .format would be unsafe and not recommended. In this case, the query is part of user + # interface, so the user has already database and table access and can edit this QLineEdit. + self.query_select_line_edit = QLineEdit("SELECT * FROM {}".format(self.selected_table_node.name)) + # Define a label with the text "WHERE" as part of the query. + self.where_label = QLabel("WHERE") + # Define a line edit for possible conditions after the WHERE in the query. + self.condition_line_edit = QLineEdit() + # Define a line edit for a limit for the query. The default limit is 1000, so there are 1000 results as a + # maximum. + self.limit_line_edit = QLineEdit("LIMIT 1000") + + # Create a button for executing the query with a SELECT for getting the necessary results. + self.execute_select_button = QPushButton("Execute") + # Connect the button with the function for executing the select query. + self.execute_select_button.clicked.connect(self.execute_select_query) + + # Create a label for showing the update statement. + self.update_label = QLabel() + # Create a button for executing the update. + self.update_button = QPushButton("Update") + self.update_button.clicked.connect(self.execute_update_query) + self.process_table_data_change() + + # Create a checkbox for another update possibility: Instead of clicking on a button for an update, update the + # table immediately after an insert. + self.update_immediately_checkbox = QCheckBox("Update values immediately") + # Connect the signal for a state change of the checkbox with the method for applying those changes. + self.update_immediately_checkbox.stateChanged.connect(self.apply_update_immediately_checkbox_changes) + # Define the name of the checkbox configuration for easier access instead of writing the name every time. + self.checkbox_configuration_name = "update_table_immediately_after_changes" + # Initialize the checkbox with its value based on the current saved configuration. + self.init_update_immediately_checkbox() + + # Execute the select query at the start/initialization of the widget. + self.execute_select_query() + + # Define a boolean for describing the current query. Before executing a query, this boolean is set to True for + # a select query and False for all the other queries (update queries). So after an update, a select can be + # executed, so the new and fresh values are immediately shown. + self.is_select_query = False + + # Adjust the size of the dialog. + self.setMaximumSize(900, 300) + self.showMaximized() + self.setWindowTitle("Edit {}".format(self.selected_table_node.name)) + self.show() + + def init_grid(self): + """ + Define a layout as a grid layout for placing the components of the widget. + """ + + # Get the layout as grid layout. + grid_layout = QGridLayout(self) + + # Set the elements for executing the select query at the top of the widget. + grid_layout.addWidget(self.query_select_line_edit, 0, 0) + grid_layout.addWidget(self.where_label, 0, 1) + grid_layout.addWidget(self.condition_line_edit, 0, 2) + grid_layout.addWidget(self.limit_line_edit, 0, 3) + grid_layout.addWidget(self.execute_select_button, 0, 4) + # Set the table under the select query elements. + grid_layout.addWidget(self.table_view, 1, 0, 1, 5) + + # Place the checkbox under the table with the data. + grid_layout.addWidget(self.update_immediately_checkbox, 7, 0) + + # Set the update items under the checkbox for choosing the update option. + grid_layout.addWidget(self.update_label, 8, 0, 1, 4) + grid_layout.addWidget(self.update_button, 8, 4) + + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def init_update_immediately_checkbox(self): + """ + Initialize the value of the checkbox with the saved configuration. + """ + + # Get the checkbox configuration out of the global app configurator. + checkbox_configuration = global_app_configurator.get_single_configuration(self.checkbox_configuration_name) + + # Set the checkbox to checked, if the configuration is True. + if checkbox_configuration is True: + self.update_immediately_checkbox.setChecked(True) + + def init_error_ui(self): + """ + Show the user interface for the error case with a wrong node type. + """ + + # Get the layout as grid layout. + grid_layout = QGridLayout(self) + # Add a label with an error. + grid_layout.addWidget(QLabel("The given node is not a table node, so editing the table is not possible."), 0, 0) + self.setLayout(grid_layout) + self.setMaximumSize(10, 100) + self.showMaximized() + # Set the title to an error title. + self.setWindowTitle("Node Input Error") + self.show() + + def refresh_data(self, data_list): + """ + Refresh the data in the table model with the new data list. + """ + + # Set the change list back to an empty list, because after the execution of a query, there will not be any + # changed data. + self.table_model.change_list = [] + self.process_table_data_change() + self.table_model.refresh_data_list(data_list) + self.table_view.resizeColumnsToContents() + + # If the executed query was not a select query, execute the current select query, so there is a table with + # result data shown in the table view. + if self.is_select_query is False: + self.execute_select_query() + + def process_error(self, error): + """ + Get the current error by a signal, which is a tuple wih the title of the error and the description. Show the + error to the user and save the message in the log. + """ + + # Get the title out of the tuple. + error_title = error[0] + # Get the description out of the tuple. + error_description = error[1] + + # Show the error to the user. + QMessageBox.critical(self, "{}".format(error_title), "The database query failed with the error: " + "{}".format(error_description)) + + # Save the error in the log. + logging.error("During the process of executing the query, an error occurred: {}".format(error_description), + exc_info=True) + + def execute_select_query(self): + """ + Execute the select query with the help of the database query executor. + """ + + # The current query is a select query, so set the boolean to True. + self.is_select_query = True + + # Get the current select query. + select_query = self.get_select_query() + + # The current query is the database query and the current database connection is the database connection. + self.database_query_executor.database_query = select_query + self.database_query_executor.database_connection = self.database_connection + # Execute the query. + self.database_query_executor.submit_and_execute_query() + + def get_select_query(self): + """ + Get the current select query based on the text in the select line edit, the condition line edit and the limit + line edit. + """ + + # Get all texts in the line edit. + select_query = self.query_select_line_edit.text() + condition_text = self.condition_line_edit.text() + limit_text = self.limit_line_edit.text() + + # If the condition is not empty, use the condition with a "WHERE". + if condition_text != "": + select_query = "{} {} {}".format(select_query, self.where_label.text(), condition_text) + + # If the limit text is not empty, use a limit. + if limit_text != "": + select_query = "{} {}".format(select_query, limit_text) + + # Return the select query without further checking, because a check is processed during the execution of the + # query. Bad and hacky behavior is possible with the editor. Persons, who use this query, have already full + # access to the table and the database. + return select_query + + def process_table_data_change(self): + """ + Process the current change in the table data with changed and currently not committed data. If there is not any + changed data, deactivate the button for an update query. + """ + + if not self.table_model.change_list: + self.update_button.setEnabled(False) + self.update_label.setText("UPDATE {}".format(self.selected_table_node.name)) + + return + + if self.update_button.isEnabled(): + return + + # Get the first changed tuple for creating an update statement. + change_tuple = self.table_model.change_list[0] + # Get the row and the column based on the changed tuple. + row = change_tuple[0] + 1 + column = change_tuple[1] + # Get the relevant value, which changed, out of the table model data list. + value = self.table_model.data_list[row][column] + + # Get the header data of the table model data list. Get a copy, so changes do not affect the original data list. + header_data = copy.copy(self.table_model.data_list[0]) + # Get the column for changes. + change_column = header_data[column] + + # Delete the column with the value in the header data, so it is not used for creating the WHERE condition. + del header_data[column] + # Get the relevant row as list, so changes are possible. + relevant_row = list(self.table_model.data_list[row]) + # Delete the column with the value in the relevant row, so it is not used for creating the WHERE condition. + del relevant_row[column] + + # Create the SET part of the statement with the column for the change and the change value. + set_statement = "SET {}='{}'".format(change_column, value) + + # Create the beginning of the WHERE condition for further adding of the single column conditions with its + # values. + where_condition = "WHERE " + + # Add every column with its value as condition. + for column_name_number in range(len(header_data)): + # Create the condition part with the column and the value. + condition = "{}='{}'".format(header_data[column_name_number], + relevant_row[column_name_number]) + + # Add the created condition to the WHERE condition. + where_condition += condition + + # If the condition is not the last one, add an AND, so further conditions are coming. + if column_name_number != len(header_data) - 1: + where_condition += " AND " + + # At this point, the end of the statement is reached and a semicolon is added. + else: + where_condition += ";" + + # Show the update statement in the label for the update statement. + update_statement = "{} {} {}".format(self.update_label.text(), set_statement, where_condition) + + # Set the text in the update label. + self.update_label.setText(update_statement) + # Activate the update button. + self.update_button.setEnabled(True) + + # If the checkbox for immediate updates is checked, execute the update query. + if self.update_immediately_checkbox.isChecked(): + self.execute_update_query() + + def execute_update_query(self): + """ + Execute the update query. + """ + + # The current query is an update query and not a select query. + self.is_select_query = False + + # The update statement is the text in the update label. + update_statement = self.update_label.text() + self.database_query_executor.database_query = update_statement + self.database_query_executor.database_connection = self.database_connection + # Execute the query. + self.database_query_executor.submit_and_execute_query() + + def apply_update_immediately_checkbox_changes(self): + """ + Apply the changes for a(n un)checked checkbox: Change the GUI and save the current configuration. + """ + + # Get the current checked value. + checked = self.update_immediately_checkbox.isChecked() + # (De)activate the GUI elements for a manual update after a change in the table. + self.de_activate_update_elements(checked) + # Set the current configuration and and save the configuration in the global app configurator. + global_app_configurator.set_single_configuration(self.checkbox_configuration_name, checked) + global_app_configurator.save_configuration_data() + + def de_activate_update_elements(self, is_active): + """ + (De)activate the GUI elements for manual updating. If the checkbox is active, deactivate the elements. + """ + + self.update_label.setVisible(not is_active) + self.update_button.setVisible(not is_active) + diff --git a/pygadmin/widgets/table_information.py b/pygadmin/widgets/table_information.py new file mode 100644 index 0000000..d5a5b62 --- /dev/null +++ b/pygadmin/widgets/table_information.py @@ -0,0 +1,161 @@ +import logging + +from PyQt5.QtWidgets import QDialog, QGridLayout, QLabel, QTableView, QMessageBox + +from pygadmin.models.treemodel import TableNode +from pygadmin.models.tablemodel import TableModel +from pygadmin.connectionfactory import global_connection_factory +from pygadmin.database_query_executor import DatabaseQueryExecutor +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class TableInformationDialog(QDialog): + """ + Create a dialog for showing the definition of a table. + """ + + def __init__(self, selected_table_node, full_definition=True): + super().__init__() + self.setModal(False) + + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + + # Check for the correct instance of the given node. + if isinstance(selected_table_node, TableNode): + # Use the given node as base and root for further information as attribute. + self.selected_table_node = selected_table_node + # Set the boolean for showing a full description to the given value. The default value is True. + self.full_definition = full_definition + # Initialize the user interface. + self.init_ui() + # Initialize the grid layout. + self.init_grid() + + # Activate the else branch for a node with the wrong type as input parameter. + else: + # Save an error in the log. + logging.error("The given node {} is not a Table Node and as a consequence, the specific actions for a Table" + " Node like showing the definition and the information are not " + "possible.".format(selected_table_node)) + + # Initialize a UI for the error case. + self.init_error_ui() + + def init_ui(self): + """ + Initialize the user interface with the relevant components. + """ + + # Use the table model as table model and use an empty data list. + self.table_model = TableModel([]) + # Use a table view with the customized model as model. + self.table_view = QTableView() + self.table_view.setModel(self.table_model) + + # Get the connection parameters based on the table node. + connection_parameters = self.selected_table_node.database_connection_parameters + + # Get the database connection based on the parameters of the table node. + self.database_connection = global_connection_factory.get_database_connection(connection_parameters["host"], + connection_parameters["user"], + connection_parameters["database"], + connection_parameters["port"], + connection_parameters["timeout"]) + # Define a query executor. + self.database_query_executor = DatabaseQueryExecutor() + # Connect the resulting data list with a function for refreshing the data in the table model. + self.database_query_executor.result_data.connect(self.refresh_data) + # Connect the error with a function for processing an error. + self.database_query_executor.error.connect(self.process_error) + + # Use the function for getting the table information. + self.get_table_information() + + # Adjust the size of the dialog. + self.setMaximumSize(720, 300) + self.showMaximized() + self.setWindowTitle("Definition of {}".format(self.selected_table_node.name)) + self.show() + + def init_grid(self): + """ + Initialize the layout as grid layout. + """ + + # Define the layout as grid layout. + grid_layout = QGridLayout(self) + # Set the table. + grid_layout.addWidget(self.table_view, 0, 0) + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def init_error_ui(self): + """ + Use a small UI for the error case of a wrong node type as input parameter. + """ + + # Get the layout as grid layout. + grid_layout = QGridLayout(self) + # Add a label with an error. + grid_layout.addWidget(QLabel("The given node is not a table node, so a definition cannot be shown."), 0, 0) + self.setLayout(grid_layout) + self.setMaximumSize(10, 100) + self.showMaximized() + # Set the title to an error title. + self.setWindowTitle("Node Input Error") + self.show() + + def get_table_information(self): + """ + Execute the database query for getting the table information. + """ + + # Check for the value of the boolean for showing the full definition. + if self.full_definition is True: + # Define the database query for the full definition. + database_query = "SELECT * FROM information_schema.columns WHERE table_name=%s;" + + else: + # Define the database query for showing the minimal definition. + database_query = "SELECT column_name, data_type, character_maximum_length " \ + "FROM information_schema.columns WHERE table_name=%s; " + + # Define the parameter, which is the name of the node. + database_query_parameter = [self.selected_table_node.name] + + # Set the current database connection as database connection of the executor. + self.database_query_executor.database_connection = self.database_connection + # Set the database query. + self.database_query_executor.database_query = database_query + # Set the database parameter. + self.database_query_executor.database_query_parameter = database_query_parameter + # Execute the database query. + self.database_query_executor.submit_and_execute_query() + + def refresh_data(self, result_data_list): + """ + Refresh the data in the tree model with a new data list, containing the new values. + """ + + self.table_model.refresh_data_list(result_data_list) + self.table_view.resizeColumnsToContents() + + def process_error(self, error): + """ + Get the current error by a signal, which is a tuple wih the title of the error and the description. Show the + error to the user and save the message in the log. + """ + + # Get the title out of the tuple. + error_title = error[0] + # Get the description out of the tuple. + error_description = error[1] + + # Show the error to the user. + QMessageBox.critical(self, "{}".format(error_title), "The database query for getting the definition failed with" + " the error: {}".format(error_description)) + + # Save the error in the log. + logging.error("During the process of executing the query, an error occurred: {}".format(error_description), + exc_info=True) diff --git a/pygadmin/widgets/tree.py b/pygadmin/widgets/tree.py new file mode 100644 index 0000000..3b6f211 --- /dev/null +++ b/pygadmin/widgets/tree.py @@ -0,0 +1,951 @@ +import copy +import logging + +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QItemSelectionModel, QModelIndex, Qt, QRunnable, QThreadPool, QObject +from PyQt5.QtGui import QStandardItemModel +from PyQt5.QtWidgets import QWidget, QTreeView, QAbstractItemView, QMessageBox, QGridLayout, QPushButton, QMenu, \ + QAction, QMainWindow + +from pygadmin.configurator import global_app_configurator +from pygadmin.connectionstore import global_connection_store +from pygadmin.models.treemodel import ServerNode, TablesNode, ViewsNode, SchemaNode, AbstractBaseNode, DatabaseNode, \ + TableNode, ViewNode +from pygadmin.widgets.node_create_information import NodeCreateInformationDialog +from pygadmin.widgets.permission_information import PermissionInformationDialog +from pygadmin.widgets.table_edit import TableEditDialog +from pygadmin.widgets.table_information import TableInformationDialog + + +class TreeWorkerSignals(QObject): + """ + Define signals for the TreeNodeWorker. + """ + + # Define a signal for the successful creation of all initial nodes. + node_creation_complete = pyqtSignal(bool) + + +class TreeNodeWorker(QRunnable): + """ + Define a class for creating all initial server nodes in a separate thread. + """ + + def __init__(self, function_to_execute, connection_parameters, server_node_list, tree_model, new_node_added_signal): + """ + Get the function to execute with its required parameters. + """ + + super().__init__() + self.function_to_execute = function_to_execute + self.connection_parameters = connection_parameters + self.server_node_list = server_node_list + self.tree_model = tree_model + self.new_node_added_signal = new_node_added_signal + self.signals = TreeWorkerSignals() + + @pyqtSlot() + def run(self): + """ + Run the function to execute with its parameters. All nodes are created at once, so the creation of all nodes is + in one separate thread. + """ + + # Execute the function. + self.function_to_execute(self.connection_parameters, self.server_node_list, self.tree_model, + self.new_node_added_signal) + + # Emit the signal for a complete creation. + self.signals.node_creation_complete.emit(True) + + +class TreeWidget(QWidget): + """ + Create a class which is a child class of QWidget as interface for the tree, which shows the available servers, + databases, schemas, views and tables in a defined structure. The widget also contains a signal for the currently + selected database or database environment. + """ + + # Define a signal for the change of database parameters, which is currently used by the widget. + database_parameter_change = pyqtSignal(dict) + new_node_added = pyqtSignal(bool) + + def __init__(self): + """ + Make sub functions for initializing the widget, separated by the parts user interface and grid + layout. + """ + + super().__init__() + self.init_ui() + self.init_grid() + + def init_ui(self): + """ + Design the user interface and its components. + """ + + # Use a tree view for showing the data in the form of a tree. + self.tree_view = QTreeView() + # Disable the possibility to edit items in the tree. + self.tree_view.setEditTriggers(QAbstractItemView.NoEditTriggers) + # Deactivate a header, because there is nothing relevant to show in the header. + self.tree_view.header().setVisible(False) + + # Define the tree model as QStandardItemModel, which is also the base for the different nodes in the tree model. + self.tree_model = QStandardItemModel() + # Connect the function for a row insert with the function for selecting an index. + self.tree_model.rowsInserted.connect(self.select_previous_selected_index) + self.selected_index = False + + # Check for empty connection parameters and warn the user in case. + if global_connection_store.get_connection_parameters_from_yaml_file() is None: + logging.warning("Database connection parameters cannot be found in " + "{}.".format(global_connection_store.yaml_connection_parameters_file)) + + QMessageBox.warning(self, "No connections in file", + "The file {} does not contain any database connection parameters.".format( + global_connection_store.yaml_connection_parameters_file)) + + # Set the actual tree model to the tree view, which is necessary for connecting the signal of the selection + # model for a current change. + self.tree_view.setModel(self.tree_model) + + # Use the selection model of the tree view to get the current selected node. + self.tree_view.selectionModel().selectionChanged.connect( + self.get_selected_element_by_signal_and_emit_database_parameter_change) + + # Create a button for adding and changing connection data. + self.add_connection_data_button = QPushButton("Add Connection Data") + # Connect a click on the button with a function for a new connection dialog. + self.add_connection_data_button.clicked.connect(self.get_new_connection_dialog) + + # Set the context menu policy to a custom context menu, so a own context menu can be used. This change of policy + # emits the following signal for a custom context menu. + self.tree_view.setContextMenuPolicy(Qt.CustomContextMenu) + # Use the signal for a custom context menu to open a context menu with the specified function. The signal uses + # the position of the mouse to transmit the clicked item. + self.tree_view.customContextMenuRequested.connect(self.open_context_menu) + + # Create a thread pool for the later usage of the tree node worker. + self.thread_pool = QThreadPool() + + # Make an empty list for initializing the variable. The variable is not changed, if the .yaml file does not + # contain any parameters. + self.server_nodes = [] + + self.setGeometry(600, 600, 500, 300) + self.setWindowTitle("Database Tree") + self.show() + + def init_data(self): + """ + Initialize the connection data and the relevant parameters at the start of the application. Use the initial data + to create a list of connection dictionaries for the initial creation of the nodes. + """ + + # Get the current connection parameters stored in the .yaml file. + current_connection_parameters = global_connection_store.get_connection_parameters_from_yaml_file() + + current_query_timeout = self.get_current_query_timeout() + + # These parameters can be None, if the .yaml file does not contain any connection parameters. + if current_connection_parameters is not None: + # Get every connection parameter dictionary and add the timeout. + for connection_parameters in current_connection_parameters: + connection_parameters["Timeout"] = current_query_timeout + + # Initialize the tree node worker for creating all initial nodes in a single thread. Use the created connection + # parameters, the server node list for adding the new node, the tree model for inserting a new node and the + # signal for emitting the addition of a new node as parameters. + tree_node_worker = TreeNodeWorker(self.create_all_initial_nodes, current_connection_parameters, + self.server_nodes, self.tree_model, self.new_node_added) + + # Connect the signal for the creation success with the function for sorting the tree, so the tree is sorted + # after the initializing of all nodes. + tree_node_worker.signals.node_creation_complete.connect(self.sort_tree) + + # Start the tree node worker. + self.thread_pool.start(tree_node_worker) + + @staticmethod + def create_all_initial_nodes(connection_parameters, server_node_list, tree_model, new_node_added_signal): + """ + Create all initial server nodes in a static function, so this function can be used by a QRunnable without + interfering in the main thread. Use the connection parameters for the creation of a new server node, the server + node and the tree model for appending the new node and the signal for emitting an information about appending a + new node. + """ + + # Create a server node for every connection dictionary. + for connection_parameter in connection_parameters: + new_node = ServerNode(name=connection_parameter["Host"], + host=connection_parameter["Host"], + user=connection_parameter["Username"], + database=connection_parameter["Database"], + port=connection_parameter["Port"], + timeout=connection_parameter["Timeout"]) + + # Append the node to the server list. + server_node_list.append(new_node) + # Insert the node in the tree model. + tree_model.insertRow(0, new_node) + # Emit the signal for a new added node. + new_node_added_signal.emit(True) + + def init_grid(self): + """ + Set a grid layout to the widget and place all its components. + """ + + # Define the layout. + grid_layout = QGridLayout() + # Set the tree view as only element on the widget. + grid_layout.addWidget(self.tree_view, 0, 0) + grid_layout.addWidget(self.add_connection_data_button, 1, 0) + + grid_layout.setSpacing(10) + + self.setLayout(grid_layout) + + def open_context_menu(self, position): + """ + Get the position of a requested context menu and open the context menu at this position with different actions. + One of these actions is to open a new connection dialog and for this, the current selected item/node is + necessary, so the corresponding connection is selected in the dialog. + """ + + # Make a new context menu as QMenu. + self.context_menu = QMenu() + + # Get the current selected node by the function for getting the selected element by the current selection. + current_selected_node = self.get_selected_element_by_current_selection() + + # Check, if the current selected node is a server node. + if isinstance(current_selected_node, ServerNode): + # Create an action for editing the database connection of the server node. + edit_connection_action = QAction("Edit Connection", self) + # Add the action to the context menu. + self.context_menu.addAction(edit_connection_action) + # Create an action for refreshing the server node. + refresh_action = QAction("Refresh", self) + # Add the action to the context menu. + self.context_menu.addAction(refresh_action) + # Get the action at the current position of the triggering event. + position_action = self.context_menu.exec_(self.tree_view.viewport().mapToGlobal(position)) + + # Check, if the action at the current position is the action for editing the connection. + if position_action == edit_connection_action: + # Show a connection dialog with the current selected node as preselected node. + self.show_connection_dialog_for_current_node(current_selected_node) + + # Check, if the action at the current position is the action for refreshing the node. + elif position_action == refresh_action: + # Refresh the current selected node. + self.refresh_current_selected_node(current_selected_node) + + if isinstance(current_selected_node, DatabaseNode): + show_create_statement_action = QAction("Show Create Statement", self) + self.context_menu.addAction(show_create_statement_action) + show_drop_statement_action = QAction("Show Drop Statement", self) + self.context_menu.addAction(show_drop_statement_action) + show_permission_information_action = QAction("Show Permissions", self) + self.context_menu.addAction(show_permission_information_action) + + position_action = self.context_menu.exec_(self.tree_view.viewport().mapToGlobal(position)) + + if position_action == show_create_statement_action: + # Use the function for getting the create statement of the database node. + self.get_create_statement_of_node(current_selected_node) + + elif position_action == show_drop_statement_action: + self.get_drop_statement_of_database_node(current_selected_node) + + elif position_action == show_permission_information_action: + self.show_permission_dialog(current_selected_node) + + # Check for a view node. + if isinstance(current_selected_node, ViewNode): + show_create_statement_action = QAction("Show Create Statement", self) + self.context_menu.addAction(show_create_statement_action) + show_permission_information_action = QAction("Show Permissions", self) + self.context_menu.addAction(show_permission_information_action) + + # Get the action at the current position of the triggering event. + position_action = self.context_menu.exec_(self.tree_view.viewport().mapToGlobal(position)) + + if position_action == show_create_statement_action: + # Use the function for getting the create statement of the view node. + self.get_create_statement_of_node(current_selected_node) + + elif position_action == show_permission_information_action: + self.show_permission_dialog(current_selected_node) + + # Check for a table node as current selected node. + if isinstance(current_selected_node, TableNode): + # Create an action for showing the definition of the node. + show_definition_action = QAction("Show Definition", self) + # Add the action to the context menu. + self.context_menu.addAction(show_definition_action) + # Create an action for showing the full definition of the node. + show_full_definition_action = QAction("Show Full Definition", self) + # Add the action to the context menu. + self.context_menu.addAction(show_full_definition_action) + # Create an action for showing the create statement of the table node. + show_create_statement_action = QAction("Show Create Statement", self) + self.context_menu.addAction(show_create_statement_action) + show_permission_information_action = QAction("Show Permissions", self) + self.context_menu.addAction(show_permission_information_action) + edit_single_values_action = QAction("Edit Single Values", self) + self.context_menu.addAction(edit_single_values_action) + # Get the action at the current position of the triggering event. + position_action = self.context_menu.exec_(self.tree_view.viewport().mapToGlobal(position)) + + # Check, if the action at the current position is the action for showing the definition of a table. + if position_action == show_definition_action: + # Show a new table dialog. + self.show_table_information_dialog(current_selected_node, False) + + # Check, if the action at the current position is the action for showing the full definition of a table + elif position_action == show_full_definition_action: + # Show the new table dialog. + self.show_table_information_dialog(current_selected_node, True) + + elif position_action == show_create_statement_action: + # Use the function for getting the create statement of the database node. + self.get_create_statement_of_node(current_selected_node) + + elif position_action == show_permission_information_action: + self.show_permission_dialog(current_selected_node) + + elif position_action == edit_single_values_action: + self.show_edit_singles_values_dialog(current_selected_node) + + def append_new_connection_parameters_and_node(self): + """ + Get new parameters out of the .yaml file, where all connections are stored, because this function should be + called after adding new database connection parameters to the file. These new parameters are used to create new + server nodes and then, they are appended to the list of all nodes and to the tree model. + """ + + # Get the new parameters. + new_connection_parameters = self.find_new_relevant_parameters() + # Get the current timeout. + current_query_timeout = self.get_current_query_timeout() + + # If the list for the new connection parameters exist, the list is not empty. + if new_connection_parameters: + # Check every possible connection in the list. + for connection_parameters in new_connection_parameters: + # Inject the current timeout in the dictionary for connection parameters. + connection_parameters["Timeout"] = current_query_timeout + # Create a new node. + new_node = self.create_new_server_node(connection_parameters) + + # If the node is a server node, append it to the list of nodes and to the tree. + if isinstance(new_node, ServerNode): + self.append_new_node(new_node) + + # This else branch is used for an empty list and as a result, non existing new connections. + else: + logging.info("New database connection parameters in {} could not be found and all previous database " + "connection parameters are currently represented in the " + "tree".format(global_connection_store.yaml_connection_parameters_file)) + + def find_new_relevant_parameters(self, position=None): + """ + Find new parameters in the .yaml file for database connection parameters. If there are new parameters, return + them in the list. If not, return an empty list. Normally, new relevant parameters are at the end of the .yaml + file. If not, there is a position parameter for the exact position of the connection. + """ + + # Create a container list for the relevant parameters. + relevant_parameters = [] + + # If the position is None, there is just an appending to the tree and so, the last parameters in the .yaml file + # can be used. + if position is None: + # Get the number of currently unused parameters as connection parameter dictionaries, which are not + # represented with server nodes at the moment. Use the function of the connection store to check the current + # number of connection parameters and the length of the list for all nodes. + number_of_unused_parameters = global_connection_store.get_number_of_connection_parameters() - \ + len(self.server_nodes) + + # If the number of currently of unused parameters is larger than 0, there are parameters, which are + # currently not represented. + if number_of_unused_parameters > 0: + # Get the list of all parameters out of the connection store. + full_parameter_list = global_connection_store.get_connection_parameters_from_yaml_file() + # Define a list for the connection dictionaries of the different server nodes. + server_node_dictionaries = [] + + # Get for every server node a connection dictionary. + for server_node in self.server_nodes: + server_connection_dictionary = {"Host": server_node.database_connection_parameters["host"], + "Database": server_node.database_connection_parameters["database"], + "Port": server_node.database_connection_parameters["port"], + "Username": server_node.database_connection_parameters["user"]} + server_node_dictionaries.append(server_connection_dictionary) + + # The relevant parameters are the one, which are only part of the list of all parameters and not part of + # the list with the server connection dictionaries. + relevant_parameters = [connection_dictionary for connection_dictionary in full_parameter_list + if connection_dictionary not in server_node_dictionaries] + + # If there is a position, the new connection parameters can be found with this information. This else branch is + # used for the change of a connection, so the position in the tree is contained. + else: + # Get the parameters out of the connection store. + relevant_connection_parameters_dictionary = global_connection_store.get_connection_at_index(position) + # Append them to the list of relevant parameters, so a node can be created. + relevant_parameters.append(relevant_connection_parameters_dictionary) + + timeout = global_app_configurator.get_single_configuration("Timeout") + + if timeout is None: + timeout = 10000 + + for connection_dictionary in relevant_parameters: + connection_dictionary["Timeout"] = timeout + + return relevant_parameters + + def create_new_server_node(self, connection_parameters_for_server_node): + """ + Take database connection parameters and use them to create a server node after a check for a duplicate. Return + the server node. + """ + + # Try to create a server node. + try: + # Check for a duplicate, because only one server node is necessary for one host, user and port. + if self.check_server_node_for_duplicate(connection_parameters_for_server_node) is not True: + server_node = ServerNode(name=connection_parameters_for_server_node["Host"], + host=connection_parameters_for_server_node["Host"], + user=connection_parameters_for_server_node["Username"], + database=connection_parameters_for_server_node["Database"], + port=connection_parameters_for_server_node["Port"], + timeout=connection_parameters_for_server_node["Timeout"]) + + else: + # If there is a duplicate, set the server node as return value to None, because a server node was not + # created. + server_node = None + + # Use an error for a failed connection. + except Exception as error: + server_node = None + # Communicate the connection error with a QMessageBox to the user. + QMessageBox.critical(self, "Connection Error", "A connection cannot be established in the tree model. " + "Please check the log for further information. " + "The resulted error is {}.".format(str(error))) + + return server_node + + def check_server_node_for_duplicate(self, new_server_node_parameters): + """ + Use the database connection parameters of a candidate for a new server node and check in the list of all server + nodes, if a (nearly) identical node exists. An identical node in this case is described as a node with the same + host, username and port. A second server node for a different database in the tree is not necessary. + """ + + # Check every existing server node with its parameters for a match in the given parameters for a new server + # node. + for server_node in self.server_nodes: + # Get the parameters of the existing server node. + server_node_parameters = server_node.database_connection_parameters + + # Check for the same host, user and port. + if server_node_parameters["host"] == new_server_node_parameters["Host"] and \ + server_node_parameters["user"] == new_server_node_parameters["Username"] and \ + server_node_parameters["port"] == new_server_node_parameters["Port"]: + # Warn the user, if one duplicate is found. + logging.warning("A server node with the connection parameters {} already exists. The database can be a " + "different one, but a new server node is not necessary for another database" + "".format(new_server_node_parameters)) + + # Use a return value to break the for loop, because further iterations are after a found not necessary. + return True + + return False + + def get_selected_element_by_signal_and_emit_database_parameter_change(self, new_item_selection, + previous_item_selection): + """ + Get the currently selected element/node in the tree view by a signal, which uses the new selection of an item + and the previous selection as parameters. The previous item contains information about the previous node and its + connection. Normally, the connection of the previous node is closed, while a new one for the new selected node + is opened. In case of identical connections parameters, the connection of the previous node is not closed, but + recycled. The function uses the currently selected node to emit a signal for changed database connection + parameters. + """ + + # Initialize the previous node as None. + previous_node = None + + # Check all indexes for the previous item selection. + for index in previous_item_selection.indexes(): + # Get the node at the item with a match for further usage. + previous_node = self.tree_model.itemFromIndex(index) + + # Check all indexes (one or None) of the new item selection. + for index in new_item_selection.indexes(): + # Get the node out of the treemodel with the index. + new_node = self.tree_model.itemFromIndex(index) + # Check the previous node for its instance, because it should be a node to match with the required + # attributes. + if isinstance(previous_node, AbstractBaseNode) and isinstance(new_node, AbstractBaseNode): + # Check the connection parameters of the previous node and the new node. If they are different, a new + # connection is required and the old one needs to be closed. + if previous_node.database_connection_parameters != new_node.database_connection_parameters: + # Close the database connection of the previous node. + previous_node.close_database_connection() + + # Update the database connection of the new, selected node, so there is an open connection available. + new_node.update_database_connection() + + # Emit the change with the parameters of the new selected node. + self.database_parameter_change.emit(new_node.database_connection_parameters) + + def get_selected_element_by_current_selection(self): + """ + Use the method of the tree view for getting all selected indexes and check for a selected index. Use the index + to get a node as currently selected element. + """ + + # Check for a selected index in the list of all selected indexes of the tree view. + for index in self.tree_view.selectedIndexes(): + # Get the node with the given index. + node = self.tree_model.itemFromIndex(index) + + # Return the node, which was found. + return node + + # Use as type for a slot parameter an object, because it can not only be a dictionary. + @pyqtSlot(object) + def select_node_for_database_parameters(self, database_parameter_dict): + """ + Select a database node in the tree model based on a dictionary with parameters. If there is a dictionary, the + nodes of the tree are checked for the same parameters. If there is compatible node, this one is selected in the + tree view. If there is not a dictionary, the current selection in the tree view is cleared. + """ + + # Check for a dictionary to read the connection parameters. + if isinstance(database_parameter_dict, dict): + # Clear the selection before enabling a new one. + self.tree_view.selectionModel().clear() + # Check every server node, because self.nodes contains all added server nodes in a list. + for server_node in self.server_nodes: + # Check the server node and the given dictionary with connection parameters for the same hostname, + # user and port. + if server_node.database_connection_parameters["host"] == database_parameter_dict["host"] \ + and server_node.database_connection_parameters["user"] == database_parameter_dict["user"] \ + and server_node.database_connection_parameters["port"] == database_parameter_dict["port"]: + + # Check for an expanded node in the tree view at the current index of the server node. If the tree + # is expanded, proceed. If not, open the tree at the current index of the server node. This course + # of action prevents bad behavior: A node can be selected, but if the tree is not expanded, this + # (database) node is unseen and it looks like a bug without being a bug. + if self.tree_view.isExpanded(server_node.index()) is False: + self.tree_view.expand(server_node.index()) + + # Use the row count of the matching server node and check every child/database. + for row in range(server_node.rowCount()): + # Get the related database node. + database_node = server_node.child(row) + # Check if the database name of the node and of the dictionary are the same. + if database_node.database_connection_parameters["database"] \ + == database_parameter_dict["database"]: + # Get the index of the database node. + database_node_index = database_node.index() + # Use the tree view to select the found database node + self.tree_view.selectionModel().select(database_node_index, + QItemSelectionModel.SelectCurrent) + + # Return nothing, so the for loops end, because a node was found. + return + + # Add an else statement to the for loop: If there is not a database, this connection is flawed. But + # the connection can still be selected, so it will be selected. + else: + self.tree_view.selectionModel().select(server_node.index(), QItemSelectionModel.SelectCurrent) + + return + + # This else is triggered, if the parameter is not a dictionary. + else: + # Select an empty model index in the tree view, so there is not a selected node. + self.tree_view.selectionModel().select(QModelIndex(), QItemSelectionModel.Clear) + + def update_tree_structure(self, change_node_information): + """ + Update the structure of the tree after creating, deleting, dropping, altering, ... tables or views or schemas or + databases with the information, which node is changed. The variable change_information is a tuple and contains a + pattern for the node and the database connection parameters to find the right node for further operations. + """ + + # Get the node pattern out of the tuple. + node_type_pattern = change_node_information[0] + + # If the pattern contains a TABLE, the relevant node is the TablesNode. + if node_type_pattern == "TABLE": + node_type = TablesNode + + # If the pattern contains a VIEW, the relevant node is the ViewsNode. + elif node_type_pattern == "VIEW": + node_type = ViewsNode + + # In the elif branch, the pattern contains a SCHEMA, so the relevant node is the SchemaNode. + elif node_type_pattern == "SCHEMA": + node_type = SchemaNode + + # The last known pattern contains a DATABASE, so the relevant node is the DatabaseNode. + else: + node_type = DatabaseNode + + # Get the connection parameters as second part of the tuple. + database_connection_parameters = change_node_information[1] + + # Check every server node for the occurrence of the database connection parameters and try to find a match. + for server_node in self.server_nodes: + # Check for host, user and port as currently relevant parameters and proceed with the match. + if server_node.database_connection_parameters["host"] == database_connection_parameters["host"] \ + and server_node.database_connection_parameters["user"] == database_connection_parameters["user"] \ + and server_node.database_connection_parameters["port"] == database_connection_parameters["port"]: + + # Check for the node type and proceed for a database node. + if node_type == DatabaseNode: + # Remove all current database nodes of the current server node. + server_node.removeRows(0, server_node.rowCount()) + # Get all children and so, the updated child will be a part of it. + server_node.get_children_with_query() + + # End the function with a return, because at this point, everything related to a new database is + # done. + return + + # Check every database node as child of a server node and its place in the row count of a server node. + for server_row in range(server_node.rowCount()): + # Get the child node of a server by the row. The child is a database node. + database_node = server_node.child(server_row) + + # Check for a match in the database with the database connection parameters. + if database_node.database_connection_parameters["database"] \ + == database_connection_parameters["database"]: + + # If a schema node is the relevant node, use this if branch. + if node_type == SchemaNode: + # Remove all nodes between the start of the database node and the end of the range of all + # children of the database node. + database_node.removeRows(0, database_node.rowCount()) + + # Generate new children. + database_node.get_children_with_query() + + # If the TablesNode or the ViewsNode is relevant, continue with the else branch. + else: + # Get every node between the begin and end of a database node. + for database_row in range(database_node.rowCount()): + # Label the nodes as children of the database node. These children are SchemaNodes. + schema_node = database_node.child(database_row) + + # Get every node in the range of this SchemaNode. + for schema_row in range(schema_node.rowCount()): + # Label the nodes as children of the schema node. TablesNodes and ViewsNodes are + # possible. + tables_views_node = schema_node.child(schema_row) + + # Check the child node for its type with is decided by the given pattern, so this + # operation is only executed by the relevant node. If the pattern describes a TABLE, + # the TablesNode is used. If the pattern describes a VIEW, a ViewNode is used. + if isinstance(tables_views_node, node_type): + # Remove all nodes between the start of the TablesNode/ViewsNode and its end. + tables_views_node.removeRows(0, tables_views_node.rowCount()) + # Reload all children and create new nodes. + tables_views_node.get_children_with_query() + + # Return at this point, so the iteration in the for loop ends fast, because a match was found + # and the relevant operations were performed. There is no need to check further nodes. + return + + def update_tree_connection(self, changed_connection_parameters_and_change_information): + """ + Update a given connection in the tree. The given parameter contains a dictionary with the connection parameters + and an information about the kind of change. If a connection is changed, there is also its position in the + global connection store. + """ + + # Get the connection parameters. + changed_connection_parameters = changed_connection_parameters_and_change_information[0] + + # Get more information about the change. This variable contains, if a connection was deleted or if a new one was + # created. + change_information = changed_connection_parameters_and_change_information[1] + + # Get the information about the changed index. + index_in_connection_store = changed_connection_parameters_and_change_information[2] + + # If the change information is new, there is a new node. + if change_information == "new": + # Use the function for appending new nodes. + self.append_new_connection_parameters_and_node() + + # End the function with a return. + return + + # Initialize the variable for the row of the server node, which will be the node with the changed connection + # parameters. + current_server_node_row = 0 + + # Define a variable for the usage of the selected index as container. + self.selected_index = False + + # Iterate over all server nodes to find the one with the given connection parameters, which were changed. + for server_node in self.server_nodes: + # Check for the right host, user, port and database. + if server_node.database_connection_parameters["host"] == changed_connection_parameters["Host"] \ + and server_node.database_connection_parameters["user"] == changed_connection_parameters[ + "Username"] \ + and server_node.database_connection_parameters["port"] == changed_connection_parameters["Port"] \ + and server_node.database_connection_parameters["database"] == changed_connection_parameters[ + "Database"]: + # Get the current row number of the index of the server node to find the part of the tree, which needs + # to be removed. + current_server_node_row = server_node.index().row() + + # Iterate over all selected indexes of the tree view. There should be None or one selected index. + for index in self.tree_view.selectedIndexes(): + # Check for the current server node, which is about to be deleted and the current selected item in + # the tree view with its index in the tree model. + if server_node == self.tree_model.itemFromIndex(index): + # Save the index in the variable. + self.selected_index = True + + # If the variable for selected index is not None, there was a selected index. This index is saved now + # for further usage. + if self.selected_index is True: + # Clear the current selection, so a new selection is not automatically chosen. + self.tree_view.selectionModel().clear() + + # Remove the node from the list of all nodes. + self.server_nodes.remove(server_node) + # Remove the node from the tree model with its row. + self.tree_model.removeRow(current_server_node_row) + + # If the change information is change, there has been a change in the connection parameters and not only a + # deletion. + if change_information == "change": + # Get the new and relevant parameters with the position of the connection parameters in the list of the + # connection store. + updated_parameter_list = self.find_new_relevant_parameters(position=index_in_connection_store) + + # If there is a new parameter in the updated list, update the tree model. + if updated_parameter_list: + # Create a new node with the first element in the list, which should be the only relevant element, + # because there should be just one change in parameters for one, single node. + new_node = self.create_new_server_node(updated_parameter_list[0]) + # Append the new node with the function for appending new nodes, so the node is appended and sorted. + self.append_new_node(new_node) + + def get_new_connection_dialog(self, current_selection_identifier=None): + """ + Get a new connection dialog and use the modified data of the dialog to change dynamically the appearance of the + tree model. The identifier for a current selection ensures a pre-selection of a connection in the connection + dialog, but the default value of the function of the main window is None, so normally, there is no + pre-selection. + """ + + # Check, if self.parent().parent() is a QMainWindow. + if isinstance(self.parent().parent(), QMainWindow): + # Use the function of the main window for activating a new connection dialog. + self.parent().parent().activate_new_connection_dialog(current_selection_identifier) + + def sort_tree(self): + """ + Sort the tree with its nodes alphabetically. Check, if sorting is enabled and if not, enable it. + """ + + # Check for enabled sorting, if not, proceed. + if not self.tree_view.isSortingEnabled(): + # Enable sorting. + self.tree_view.setSortingEnabled(True) + + # Sort in the "wrong" order. This is necessary for the call of this function after a structural change in the + # tree, for example after adding a new node. This new node needs to be sorted correctly, a simple call of a new + # sort is not effective and does not change the current order, so a new node is not sorted. To prevent this, a + # "wrong" sort order is used, so the sort after that is correct. + self.tree_view.sortByColumn(0, 1) + # Use the correct sort order for ensuring the right sort for all nodes. + self.tree_view.sortByColumn(0, 0) + + @staticmethod + def get_current_query_timeout(): + """ + Get the current timeout of a query. + """ + + # Use the global app configurator to get the current timeout time. + current_query_timeout = global_app_configurator.get_single_configuration("Timeout") + + # If a timeout cannot be found, set the timeout to 10000. + if current_query_timeout is None: + current_query_timeout = 10000 + + # Return the timeout. + return current_query_timeout + + def append_new_node(self, server_node): + """ + Append a new server node to the list of server nodes and as row to the tree model. Sort the tree after + appending. This function does not select a potential previous selected node, because this is realized by a + signal, which ensures the correct handling of the asynchronous thread. + """ + + # Append the server node to the list of server nodes. + self.server_nodes.append(server_node) + # Insert the node in the tree. 0 is used, because there is only one column. + self.tree_model.insertRow(0, server_node) + + # Sort the database tree. + self.sort_tree() + + self.new_node_added.emit(True) + + def select_previous_selected_index(self, parent_index, first_item, last_item): + """ + Select a previous selected index. This can be used for a change in a node, so the attribute selected index is + True. This function is called by the signal rowsInserted. This signal sends the parent index, the first item + and the last item as parameters. + """ + + # Check, if the selected index is True. This variable contains a boolean, which is set to True for the change of + # a node. + if self.selected_index is True: + # Clear the previous selection. + self.tree_view.selectionModel().clear() + # Get the index of the inserted row with the tree model and the first item. + inserted_row_index = self.tree_model.index(first_item, 0) + # Set the current selected index as the inserted row index. + self.tree_view.selectionModel().setCurrentIndex(inserted_row_index, QItemSelectionModel.Select) + + def show_connection_dialog_for_current_node(self, node_information): + """ + Get the a node as node information and get the database connection parameters of the node. Use the connection + parameters for identifying a connection identifier and open a connection dialog with a selected identifier. The + selected identifier is the identifier of the node. + """ + + # Get the connection parameters. + connection_parameters = node_information.database_connection_parameters + # Create a connection identifier out of the parameters of the node. + current_selected_identifier = "{}@{}:{}/{}".format(connection_parameters["user"], + connection_parameters["host"], + connection_parameters["port"], + connection_parameters["database"]) + + # Get a new connection dialog with a pre-selection of the currently selected connection of the node. + self.get_new_connection_dialog(current_selected_identifier) + + def show_table_information_dialog(self, node_information, show_full_definition): + """ + Show a table information dialog based on a node. The full definition of a table is shown, if the full definition + is set as True. + """ + + self.table_information_dialog = TableInformationDialog(node_information, show_full_definition) + + def show_permission_dialog(self, node_information): + """ + Show a permission dialog for the given node. + """ + + self.permission_information_dialog = PermissionInformationDialog(node_information) + + def show_edit_singles_values_dialog(self, current_node): + """ + Create a table edit dialog for changing single values in the current node. + """ + + self.table_edit_dialog = TableEditDialog(current_node) + + def refresh_current_selected_node(self, current_node): + """ + Get the current server node and refresh this node with the information about the database connection parameters, + define the change information and use the index in the global connection store. Store the resulting variables in + a list for usage in the function for updating the tree connection. + """ + + # Map the database parameters of the server node to the more readable parameters, which are used in the + # connection store and connection dialog. + database_parameters = {"Host": current_node.database_connection_parameters["host"], + "Database": current_node.database_connection_parameters["database"], + "Username": current_node.database_connection_parameters["user"], + "Port": current_node.database_connection_parameters["port"], + } + # Define the change information as change, which is normally used for a change in connection parameters. Here, + # it is used for a refresh. + change_information = "change" + # Get the index in the connection store for the database parameters. + index_in_connection_store = global_connection_store.get_index_of_connection(database_parameters) + # Get the information in a information list. + information_list = [database_parameters, change_information, index_in_connection_store] + + # Use the function for updating the tree connection (or the node's connection) with the function for updating. + self.update_tree_connection(information_list) + + def get_create_statement_of_node(self, current_node): + """ + Create a database information dialog with the current selected/given node. The dialog contains the create + statement of the node. + """ + + self.create_information_dialog = NodeCreateInformationDialog(current_node) + + def get_drop_statement_of_database_node(self, current_node): + """ + Use the current node for getting a drop statement. The drop statement contains an optional part for closing the + database connection, which can be used by the user for an unsuccessful drop. The query and the database + connection parameters of the node are used as input parameter for a function of the main window for showing an + editor with the statement. In addition, postgres as database is chosen as standard database, so a drop is + executed with the connection to the database postgres. + """ + + # This query/statement does not have to be execute the way it is. The user will have the possibility to execute + # it after seeing in the GUI, so an SQL injection is not a problem, because the user will have the possibility + # to edit the statement if necessary. + optional_close_and_drop_statement = "--SELECT pg_terminate_backend(pg_stat_activity.pid)\n" \ + "--FROM pg_stat_activity\n" \ + "--WHERE datname='{}'\n" \ + "DROP DATABASE {};".format(current_node.name, current_node.name) + + # Get the database connection parameters of the current node. A copy is made, because the database connection + # parameters are going to be modified. + parameters_current_node = copy.copy(current_node.database_connection_parameters) + # Remove the key value pair for timeout, because the timeout is not necessary for the further dictionary + # comparison. + parameters_current_node.pop("timeout", None) + # Set the database to postgres. + parameters_current_node["database"] = "postgres" + + # The purpose of this whole for block is selecting the database node with the database postgres. To achieve this + # goal, every server node is checked for the right host, user and database. + for server_node in self.server_nodes: + if server_node.database_connection_parameters["host"] == parameters_current_node["host"] and \ + server_node.database_connection_parameters["user"] == parameters_current_node["user"] \ + and server_node.database_connection_parameters["port"] == parameters_current_node["port"]: + # Check every database node as child of a server node and its place in the row count of a server node. + for server_row in range(server_node.rowCount()): + # Get the child node of a server by the row. The child is a database node. + database_node = server_node.child(server_row) + + # Check for a match in the database with the database connection parameters. + if database_node.database_connection_parameters["database"] == parameters_current_node["database"]: + # Get the new index for the selection by the index of the matching database node. + new_index_for_selection = self.tree_model.indexFromItem(database_node) + # Select the new index and the database node for the database postgres. + self.tree_view.selectionModel().select(new_index_for_selection, + QItemSelectionModel.SelectCurrent) + + # Use the function of the parent's parent (the main window) for loading an editor with the given connection and + # the given drop statement. + self.parent().parent().load_editor_with_connection_and_query(parameters_current_node, + optional_close_and_drop_statement) diff --git a/pygadmin/widgets/version_information_dialog.py b/pygadmin/widgets/version_information_dialog.py new file mode 100644 index 0000000..9def17d --- /dev/null +++ b/pygadmin/widgets/version_information_dialog.py @@ -0,0 +1,51 @@ +from PyQt5.QtWidgets import QDialog, QLabel, QGridLayout +import pygadmin +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class VersionInformationDialog(QDialog): + """ + Create a dialog for showing the current version of pygadmin. + """ + + def __init__(self): + """ + Use the methods for initializing the dialog. + """ + + super().__init__() + # Add the pygadmin icon as window icon. + icon_adder = IconAdder() + icon_adder.add_icon_to_widget(self) + self.init_ui() + self.init_grid() + + def init_ui(self): + """ + Create a user interface. + """ + + # Create a label for showing the current version. + self.version_label = QLabel() + # Set the current version to the label. + self.set_version_to_label() + self.setMaximumSize(200, 60) + self.setWindowTitle("Current Version") + self.show() + + def init_grid(self): + """ + Create a grid layout. + """ + + grid_layout = QGridLayout(self) + grid_layout.addWidget(self.version_label, 0, 0) + grid_layout.setSpacing(10) + self.setLayout(grid_layout) + + def set_version_to_label(self): + """ + Set the current version of pygadmin as label text. + """ + + self.version_label.setText("You are currently using version {} of pygadmin.".format(pygadmin.__version__)) diff --git a/pygadmin/widgets/widget_icon_adder.py b/pygadmin/widgets/widget_icon_adder.py new file mode 100644 index 0000000..8eac829 --- /dev/null +++ b/pygadmin/widgets/widget_icon_adder.py @@ -0,0 +1,42 @@ +import logging +import os + +from PyQt5.QtGui import QIcon, QPixmap + +import pygadmin + + +class IconAdder: + """ + Create a class for adding the pygadmin icon as window icon for the given widget. + """ + + def __init__(self): + """ + Define the icon path and get the icon. + """ + + # Define the icon path. The pygadmin icon can be found in this path. + icon_path = os.path.join(os.path.dirname(pygadmin.__file__), "icons", "pygadmin.svg") + + # Check for the existence of the path. + if os.path.exists(icon_path): + # Create a QIcon as window icon. + self.window_icon = QIcon() + # Add the pygadmin logo as pixmap to the icon. + self.window_icon.addPixmap(QPixmap(icon_path)) + + # Define a behavior for a missing path. + else: + # Set the window icon to an empty icon. + self.window_icon = QIcon() + # Show a warning. + logging.warning("The window icon could not be found in {}".format(icon_path)) + + def add_icon_to_widget(self, widget): + """ + Get a widget and add the pygadmin icon as window icon. + """ + + # Set the icon as window icon. + widget.setWindowIcon(self.window_icon) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2cd2b0f --- /dev/null +++ b/setup.py @@ -0,0 +1,79 @@ +import io +import os +import subprocess +from setuptools import setup, find_packages + + +# Utility function to read the README file. +# Used for the long_description. It's nice, because now 1) we have a top level +# README file and 2) it's easier to type in the README file than to put a raw +# string in below ... +def read(filename): + with io.open(os.path.join(os.path.dirname(__file__), filename)) as f: + return f.read() + + +def determine_version(): + dir_path = os.path.dirname(os.path.realpath(__file__)) + ver_file = os.path.join(dir_path, "version.txt") + version = "0.0.0" + if os.path.exists(ver_file): + version = read(ver_file) + # If this is a release file and no git is found, use version.txt + if not os.path.isdir(os.path.join(dir_path, ".git")): + return version + # Derive version from git + try: + output = subprocess.check_output(['git', 'describe', '--tags', '--dirty'], cwd=dir_path) \ + .decode('utf-8').strip().split('-') + if len(output) == 1: + return output[0] + elif len(output) == 2: + return "{}.dev0".format(output[0]) + else: + release = 'dev' if len(output) == 4 and output[3] == 'dirty' else '' + return "{}.{}{}+{}".format(output[0], release, output[1], output[2]) + except subprocess.CalledProcessError: + try: + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip() + status = subprocess.check_output(['git', 'status', '-s']).decode('utf-8').strip() + return "{}.dev0+{}".format(version, commit) if len(status) > 0 else "{}+{}".format(version, commit) + except subprocess.CalledProcessError: + # finding the git version has utterly failed, use version.txt + return version + + +setup( + name="Pygadmin", + version=determine_version(), + author="KDV", + author_email="pygadmin@kdv.bayern", + description="A QT-based database administration tool for PostgreSQL databases", + url="https://www.kdv.bayern/", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: Unix", + "Operating System :: Microsoft :: Windows" + ], + python_requires='>=3.6', + install_requires=[ + "psycopg2", + "PyYAML", + "PyQt5", + "QScintilla", + "keyring", + ], + packages=find_packages(), + package_data={ + 'pygadmin': [ + 'icons/*', + 'logging.yaml', + ], + }, + entry_points={ + "gui_scripts": [ + "pygadmin = pygadmin:main", + ] + }, +) diff --git a/tests/test_command_history.py b/tests/test_command_history.py new file mode 100644 index 0000000..f62e480 --- /dev/null +++ b/tests/test_command_history.py @@ -0,0 +1,220 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication, QLabel, QListWidget + +from pygadmin.widgets.command_history import CommandHistoryDialog +from pygadmin.command_history_store import global_command_history_store +from pygadmin.configurator import global_app_configurator + + +class TestCommandHistoryDialogMethods(unittest.TestCase): + """ + Test the functionality and methods of the command history dialog. + """ + + def test_empty_dialog(self): + """ + Test the dialog without data in the command history store, so the dialog shows a warning about the empty + history. + """ + + # Get the current command history for saving it during the testing of the method. Later, the current command + # history is saved again in the command history store. + current_command_history = global_command_history_store.get_command_history_from_yaml_file() + + # Delete all commands from the history, so the history is empty. As a result, the dialog should show the warning + # ui. + global_command_history_store.delete_all_commands_from_history() + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a command history dialog. + command_history_dialog = CommandHistoryDialog() + + # Check for the existence and correct instance of the empty label. + assert isinstance(command_history_dialog.empty_label, QLabel) + + # Set the list with the data about the last commands as command history list. + global_command_history_store.command_history_list = current_command_history + # Save the list in the yaml file again. + global_command_history_store.commit_current_list_to_yaml() + + def test_initial_attributes(self): + """ + Test the initial attributes of the dialog (with an existing command history). + """ + + # Define a dictionary with a command and the information about it, so there is at least one command in the + # command history. + command_dictionary = {"Command": "SELECT * FROM test;", + "Identifier": "testuser@testserver:5432/testdb", + "Time": "2020-10-01 11:53:59", + "Result": [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], + ["row D", "row E", "row F"]]} + + global_command_history_store.save_command_history_in_yaml_file(command_dictionary) + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a command history dialog. + command_history_dialog = CommandHistoryDialog() + + # Check for the GUI attributes, so the QListWidget and the QLabels. + assert isinstance(command_history_dialog.history_list_widget, QListWidget) + assert isinstance(command_history_dialog.command_label, QLabel) + assert isinstance(command_history_dialog.connection_identifier_label, QLabel) + assert isinstance(command_history_dialog.date_time_label, QLabel) + # The data list of the table model should be empty at the beginning. + assert command_history_dialog.table_model.data_list == [] + + # Check for the existence and correct instance of the command history list. + assert isinstance(global_command_history_store.command_history_list, list) + + # Clean up, so the testing command is no longer part of the command history store. + global_command_history_store.delete_command_from_history(command_dictionary) + + def test_show_command_information_in_labels(self): + """ + Test the method for showing the command information in the given labels and the table view, so the data list of + the table model is checked. + """ + + # Define a dictionary with a command and the information about it, so there is at least one command in the + # command history. + command_dictionary = {"Command": "SELECT * FROM test;", + "Identifier": "testuser@testserver:5432/testdb", + "Time": "2019-05-04 13:37:00", + "Result": [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], + ["row D", "row E", "row F"]]} + + global_command_history_store.save_command_history_in_yaml_file(command_dictionary) + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a command history dialog. + command_history_dialog = CommandHistoryDialog() + + # Define an index for the last item for iterating. This index is used later for setting the current row as + # selection. + index_of_last_item = 0 + # Define an identifier text for finding the command of the previous defined command dictionary. + command_identifier_text = "{}\n{}".format(command_dictionary["Command"], command_dictionary["Time"]) + + # Iterate over every item in the list widget. + for item_count in range(command_history_dialog.history_list_widget.count()): + # If the text of the current item is the same as in the command identifier text, use the current item count + # as index of the last item. + if command_identifier_text == command_history_dialog.history_list_widget.item(item_count).text(): + index_of_last_item = item_count + # End the loop, because further iterating is not necessary. There is already a match. + break + + # Set the index of the last item as current row. + command_history_dialog.history_list_widget.setCurrentRow(index_of_last_item) + + # Check the labels and the data list of the tree model for the correct list. + assert command_history_dialog.command_label.text() == command_dictionary["Command"] + assert command_history_dialog.connection_identifier_label.text() == command_dictionary["Identifier"] + assert command_history_dialog.date_time_label.text() == command_dictionary["Time"] + assert command_history_dialog.table_model.data_list == command_dictionary["Result"] + + # Clean up, so the testing command is no longer part of the command history store. + global_command_history_store.delete_command_from_history(command_dictionary) + + def test_get_command_dictionary_of_current_selected_identifier(self): + """ + Test the method for getting the command dictionary of the current selected identifier in the history list + widget. + """ + + # Define a dictionary with a command and the information about it, so there is at least one command in the + # command history. + command_dictionary = {"Command": "SELECT * FROM test;", + "Identifier": "testuser@testserver:5432/testdb", + "Time": "2019-05-04 13:37:00", + "Result": [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], + ["row D", "row E", "row F"]]} + + global_command_history_store.save_command_history_in_yaml_file(command_dictionary) + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a command history dialog. + command_history_dialog = CommandHistoryDialog() + + # Define an index for the last item for iterating. This index is used later for setting the current row as + # selection. + index_of_last_item = 0 + # Define an identifier text for finding the command of the previous defined command dictionary. + command_identifier_text = "{}\n{}".format(command_dictionary["Command"], command_dictionary["Time"]) + + # Iterate over every item in the list widget. + for item_count in range(command_history_dialog.history_list_widget.count()): + # If the text of the current item is the same as in the command identifier text, use the current item count + # as index of the last item. + if command_identifier_text == command_history_dialog.history_list_widget.item(item_count).text(): + index_of_last_item = item_count + # End the loop, because further iterating is not necessary. There is already a match. + break + + # Set the index of the last item as current row. + command_history_dialog.history_list_widget.setCurrentRow(index_of_last_item) + + # Get the command dictionary of the selected command. + selected_command_dictionary = command_history_dialog.get_command_dictionary_of_current_selected_identifier() + + # The dictionary of the selected item should be the command dictionary. + assert selected_command_dictionary == command_dictionary + + # Clean up, so the testing command is no longer part of the command history store. + global_command_history_store.delete_command_from_history(command_dictionary) + + def test_save_current_command_limit(self): + """ + Test the function for saving the current command limit with the input in the line edit. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a command history dialog. + command_history_dialog = CommandHistoryDialog() + + # Define a new command limit. + new_command_limit = 15 + + # Set the command limit as string as text of the command limit line edit. + command_history_dialog.command_limit_line_edit.setText(str(new_command_limit)) + # Save the current command limit. + command_history_dialog.save_current_command_limit() + + # The command limit in the app configurator should now be the pre-defined new command limit. + assert global_app_configurator.get_single_configuration("command_limit") == new_command_limit + + def test_check_valid_command_limit(self): + """ + Test the function for checking a valid comment limit in the command limit line edit. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a command history dialog. + command_history_dialog = CommandHistoryDialog() + + # A normal text, which is not equivalent to None and cannot be casted to an integer, should be invalid. + command_history_dialog.command_limit_line_edit.setText("test") + assert command_history_dialog.check_valid_command_limit() is False + + # -1 as text can be casted to an integer, but the command limit needs to be larger than 0. + command_history_dialog.command_limit_line_edit.setText("-1") + assert command_history_dialog.check_valid_command_limit() is False + + # The text None should be accepted. + command_history_dialog.command_limit_line_edit.setText("None") + assert command_history_dialog.check_valid_command_limit() is True + + # The text 42 can be casted to a valid integer value. + command_history_dialog.command_limit_line_edit.setText("42") + assert command_history_dialog.check_valid_command_limit() is True + + diff --git a/tests/test_command_history_store.py b/tests/test_command_history_store.py new file mode 100644 index 0000000..807d5bb --- /dev/null +++ b/tests/test_command_history_store.py @@ -0,0 +1,169 @@ +import unittest +import os + +from pygadmin.command_history_store import global_command_history_store +from pygadmin.configurator import global_app_configurator + + +class TestCommandHistoryStoreMethods(unittest.TestCase): + """ + Test the command history store with its method and its behavior. + """ + + def test_path_of_command_history_file(self): + """ + Check for the existence of the yaml file, which stores the command history. + """ + + assert os.path.exists(global_command_history_store.yaml_command_history_file) + + def test_command_history_list(self): + """ + Test the existence and the correct type of the command history list. + """ + + assert isinstance(global_command_history_store.command_history_list, list) + + def test_get_command_history_from_yaml_file(self): + """ + Test the behavior of the method for getting the current command history from the yaml file. + """ + + # Get the current list. + command_history_list = global_command_history_store.get_command_history_from_yaml_file() + # The result of the method should be the current data list of the command history store. + assert command_history_list == global_command_history_store.command_history_list + + def test_commit_current_list_to_yaml(self): + """ + Test the correct commit of the current list to the yaml file. + """ + + # Ensure the correct load of all previous commands in the history. + global_command_history_store.get_command_history_from_yaml_file() + # The result of committing should be True for a success. + assert global_command_history_store.commit_current_list_to_yaml() is True + + def test_save_command_history_in_yaml_file(self): + """ + Test the function for saving one specific command in the command history. + """ + + # Define a dictionary with a command and the information about it. + command_dictionary = {"Command": "SELECT * FROM test;", + "Identifier": "testuser@testserver:5432/testdb", + "Time": "2020-10-01 11:53:59", + "Result": [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], + ["row D", "row E", "row F"]]} + + # Save the command dictionary in the yaml file. + global_command_history_store.save_command_history_in_yaml_file(command_dictionary) + # Now the dictionary should be part of the command history list. + assert command_dictionary in global_command_history_store.command_history_list + + # Clean up, so the testing command is no longer part of the command history store. + global_command_history_store.delete_command_from_history(command_dictionary) + + def test_delete_command_from_history(self): + """ + Test the deletion of a command from the history. + """ + + # Define a dictionary with a command and the information about it. + command_dictionary = {"Command": "SELECT * FROM test;", + "Identifier": "testuser@testserver:5432/testdb", + "Time": "2020-10-01 11:53:59", + "Result": [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], + ["row D", "row E", "row F"]]} + + # Save the command dictionary in the yaml file. + global_command_history_store.save_command_history_in_yaml_file(command_dictionary) + + # The deletion of the dictionary should return True as a success. + assert global_command_history_store.delete_command_from_history(command_dictionary) is True + # A second try with the same dictionary should return False, because the dictionary is already deleted and can + # not be found. + assert global_command_history_store.delete_command_from_history(command_dictionary) is False + + def test_delete_all_commands_from_history(self): + """ + Test the deletion of the complete history. + """ + + # Get the current command history for saving it again later. + current_command_history = global_command_history_store.get_command_history_from_yaml_file() + + # The deletion of the whole history should be successful. + assert global_command_history_store.delete_all_commands_from_history() is True + assert global_command_history_store.command_history_list == [] + + # Set the previous saved list as command history list for restoring the correct list. + global_command_history_store.command_history_list = current_command_history + # Save the correct list in the yaml file. + global_command_history_store.commit_current_list_to_yaml() + + def test_get_new_command_limit(self): + """ + Test the method for getting the new command limit in the command history store. + """ + + # Define a command limit. + command_limit = 100 + # Set the command limit in the global app configurator. + global_app_configurator.set_single_configuration("command_limit", command_limit) + global_app_configurator.save_configuration_data() + + # Get the new command limit as attribute of the class. + global_command_history_store.get_new_command_limit() + + # The command limit of the global history store should be the command limit, which was set before. + assert global_command_history_store.command_limit == command_limit + + def test_adjust_saved_history_to_new_command_limit(self): + """ + Test the method for adjusting an existing list of commands in the history to a new command limit. + """ + + # Define a previous command limit. + old_command_limit = 100 + # Set the command limit in the global app configurator. + global_app_configurator.set_single_configuration("command_limit", old_command_limit) + global_app_configurator.save_configuration_data() + + # Define a new command limit. + command_limit = 10 + + # Add new command dictionaries to the command history. + for command_number in range(command_limit + 2): + # Define a unique command dictionary. + command_dictionary = {"Command": "{}".format(command_number), + "Identifier": "testuser@testserver:5432/testdb", + "Time": "2020-10-01 11:53:{}".format(command_number), + "Result": [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], + ["row D", "row E", "row F"]]} + + # Save the unique command dictionary in the command history store. + global_command_history_store.save_command_history_in_yaml_file(command_dictionary) + + # Set the command limit in the global app configurator. + global_app_configurator.set_single_configuration("command_limit", command_limit) + global_app_configurator.save_configuration_data() + + # Use the function for adjusting the saved history to the new command list and commit the new list to the yaml + # file. + global_command_history_store.adjust_saved_history_to_new_command_limit() + global_command_history_store.commit_current_list_to_yaml() + + # The length of the list should be the command limit. + assert len(global_command_history_store.command_history_list) == command_limit + + # Define a test command dictionary. This dictionary was inserted before, but it was too old, so it should be + # deleted. + test_command_dictionary = {"Command": "1", + "Identifier": "testuser@testserver:5432/testdb", + "Time": "2020-10-01 11:53:1", + "Result": [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], + ["row D", "row E", "row F"]]} + + # The test dictionary should not be part of the command history list, because it is deleted. + assert test_command_dictionary not in global_command_history_store.command_history_list diff --git a/tests/test_configuration_settings.py b/tests/test_configuration_settings.py new file mode 100644 index 0000000..1b4d757 --- /dev/null +++ b/tests/test_configuration_settings.py @@ -0,0 +1,107 @@ +import unittest +import sys + +from PyQt5.QtWidgets import QApplication, QDialog, QCheckBox, QLineEdit + +from pygadmin.configurator import global_app_configurator +from pygadmin.widgets.configuration_settings import ConfigurationSettingsDialog + + +class TestConfigurationSettingsDialogMethods(unittest.TestCase): + """ + Test the functionality and methods of the configuration settings dialog. + """ + + def test_initial_attributes(self): + """ + Test the correct class and the initial attributes of the configuration settings dialog. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create an dialog. + configuration_settings_dialog = ConfigurationSettingsDialog() + # Check the correct instance. + assert isinstance(configuration_settings_dialog, QDialog) + # Check for the correct instance of the configuration dictionary. + assert isinstance(configuration_settings_dialog.configuration_dictionary, dict) + + def test_save_current_configuration(self): + """ + Test the save of the current configuration: Check the text or check box status of the GUI elements and their + correct save in the dictionary. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + configuration_settings_dialog = ConfigurationSettingsDialog() + + # The function should return True for a success. + assert configuration_settings_dialog.save_current_configuration() is True + + # Check the correct save of every user input. + for description, gui_elements in configuration_settings_dialog.configuration_dictionary.items(): + # The dialog shows the configurations in a more readable version. + description = description.replace(" ", "_") + # Get the current configuration. + configuration = global_app_configurator.get_single_configuration(description) + + # The second element of gui_elements contains the element for user interaction, so this one is checked. + user_element = gui_elements[1] + + # Check for a QCheckBox as potential element. + if isinstance(user_element, QCheckBox): + # Check for the correct configuration. + assert user_element.isChecked() == configuration + + # Check for a QLineEdit as potential element. + elif isinstance(user_element, QLineEdit): + # Check for the correct configuration. + assert configuration == user_element.text() + + def test_save_current_configuration_and_close(self): + """ + Test the method for saving the current configuration and close after that. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + configuration_settings_dialog = ConfigurationSettingsDialog() + # Ensure a visible dialog. + assert configuration_settings_dialog.isVisible() is True + # Save the configuration and close the dialog. + configuration_settings_dialog.save_current_configuration_and_close() + # The dialog should be invisible after a close. + assert configuration_settings_dialog.isVisible() is False + + def test_check_for_unsaved_configurations(self): + """ + Test the method for checking for unsaved configurations. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + configuration_settings_dialog = ConfigurationSettingsDialog() + + # At this moment, all configurations are saved/freshly initialized. + assert configuration_settings_dialog.check_for_unsaved_configuration() is False + + # Change one existing element, so there are unsaved configurations. + for gui_elements in configuration_settings_dialog.configuration_dictionary.values(): + # The second element is the element for user interaction. + user_element = gui_elements[1] + + # Check for a checkbox. + if isinstance(user_element, QCheckBox): + # Get the current state of the checkbox. + current_state = user_element.isChecked() + # Reverse the state, so there is an unsaved configuration now. + user_element.setChecked(not current_state) + # Check for an unsaved configuration. This should be True. + assert configuration_settings_dialog.check_for_unsaved_configuration() is True + # Break the loop after an assertion. + break + diff --git a/tests/test_configurator.py b/tests/test_configurator.py new file mode 100644 index 0000000..f72754b --- /dev/null +++ b/tests/test_configurator.py @@ -0,0 +1,152 @@ +import unittest +import os + +from pygadmin.configurator import global_app_configurator + + +class TestConfiguratorMethods(unittest.TestCase): + """ + Test the app configurator with its methods and behavior. + """ + + def test_path_of_configuration_files(self): + """ + Test the existence of the two file paths. + """ + + # Get the path for the general configuration file and test it. + configuration_file = global_app_configurator.yaml_app_configuration_file + assert os.path.exists(configuration_file) + + # Get the path for the editor style configuration file and test it. + editor_style_file = global_app_configurator.yaml_editor_style_configuration_file + assert os.path.exists(editor_style_file) + + def test_configuration_dictionary(self): + """ + Test the correct load of the configuration dictionary. + """ + + assert global_app_configurator.configuration_dictionary is not None + assert isinstance(global_app_configurator.configuration_dictionary, dict) + + def test_style_configuration_dictionary(self): + """ + Test the correct load of the editor style dictionary. + """ + + assert global_app_configurator.editor_style_dictionary is not None + assert isinstance(global_app_configurator.editor_style_dictionary, dict) + + def test_save_configuration_data(self): + """ + Test the save of all current configuration data, which should return True for a success. + """ + + assert global_app_configurator.save_configuration_data() is True + + def test_save_style_data(self): + """ + Test the save of all current style data, which should return True for a success. + """ + + assert global_app_configurator.save_style_configuration_data() is True + + def test_get_all_current_configurations(self): + """ + Test getting all current configurations. The result should be a dict. + """ + + assert isinstance(global_app_configurator.get_all_current_configurations(), dict) + + def test_get_all_current_style_themes(self): + """ + Test getting all current style themes and the correct structure of the returning dictionary. + """ + + # Get the style dictionary. + style_dictionary = global_app_configurator.get_all_current_color_style_themes() + + # Test for the right instance. + assert isinstance(style_dictionary, dict) + + # Test every value in the dictionary for the correct instance, which should also be a dictionary. + for value in style_dictionary.values(): + assert isinstance(value, dict) + + def test_set_single_configuration(self): + """ + Set a single configuration and test for correct setting with direct access to the dictionary. + """ + + # Define a key and a value for testing. + test_key = "test" + test_value = True + + # Set the configuration. + global_app_configurator.set_single_configuration(test_key, test_value) + # Get the value to the key with direct access to the dictionary. + assert global_app_configurator.configuration_dictionary[test_key] is test_value + + def test_get_single_configuration(self): + """ + Set a single configuration and test for the correct setting and getting with the method of the app configurator. + """ + + # Define a test key and a test value. + test_key = "test" + test_value = True + + # Set the configuration. + global_app_configurator.set_single_configuration(test_key, test_value) + # Get the value to the key with the method of the app configurator. + assert global_app_configurator.get_single_configuration(test_key) is test_value + + def test_delete_configuration(self): + """ + Set a configuration and delete the configuration again for testing the correct deletion. Test also the case for + a non existing configuration. + """ + + # Define a test key and a test value. + test_key = "test" + test_value = True + + # Set the configuration. + global_app_configurator.set_single_configuration(test_key, test_value) + # Delete the configuration based on its key and check if the result is True for a successful deletion. + assert global_app_configurator.delete_single_configuration(test_key) is True + # Delete the configuration based on its key again, which should fail, so the result is False. + assert global_app_configurator.delete_single_configuration(test_key) is False + + def test_get_all_configurations(self): + """ + Test to get all components and check the correctness with direct access. + """ + + configurations = global_app_configurator.get_all_current_configurations() + assert configurations == global_app_configurator.configuration_dictionary + + def test_default_color_theme(self): + """ + Test to get the default color theme. + """ + + # Get the color theme out of the global app configurator. + color_theme = global_app_configurator.get_single_configuration("color_theme") + + # Proceed, if the editor style dictionary and the color theme is not None. So there are elements in the style + # dictionary and there is a color theme. + if global_app_configurator.editor_style_dictionary and color_theme: + # Get the style description and the style values (as a dictionary). + style_description, style_values = global_app_configurator.get_default_color_theme_style() + # The first value should be the color theme, + assert style_description == color_theme + # The second value should contain a dictionary with the different color themes. + assert isinstance(style_values, dict) + + # If the color theme or the style dictionary (or both) is None or empty, the result for the default color theme + # should also be None. + else: + assert global_app_configurator.get_default_color_theme_style() is None + diff --git a/tests/test_connection_dialog.py b/tests/test_connection_dialog.py new file mode 100644 index 0000000..156ca55 --- /dev/null +++ b/tests/test_connection_dialog.py @@ -0,0 +1,503 @@ +import sys +import unittest +import keyring + +from PyQt5.QtWidgets import QApplication, QLineEdit, QListWidgetItem + +from pygadmin.widgets.connection_dialog import ConnectionDialogWidget +from pygadmin.connectionstore import global_connection_store + + +class TestConnectionDialogMethods(unittest.TestCase): + """ + Test the functionality and methods of the connection dialog in some essential aspects. + """ + + def test_initial_attributes(self): + """ + Test basic attributes of the class after initializing. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Check for the connection parameter edit dictionary, which is an essential element to the dialog. + assert isinstance(connection_dialog.connection_parameter_edit_dictionary, dict) + + # Check every value in the dictionary for a QLineEdit. + for value in connection_dialog.connection_parameter_edit_dictionary.values(): + # The elements should be QLineEdits. + assert isinstance(value, QLineEdit) + + # The label for the status for testing the given connection should have the correct text. + assert connection_dialog.test_given_connection_status_label.text() == "Not tested yet" + + def test_check_for_empty_parameter_fields(self): + """ + Test the function for checking for empty parameter fields. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Get the current result for the check of empty parameter fields. It should not be a simple boolean, so it will + # be a list. + empty_parameter_result = connection_dialog.check_for_empty_parameter_edit_fields() + + # The first parameter of the result list is a boolean, which should be True in this case. + assert empty_parameter_result[0] is True + + # Set to every edit field a value. + for edit_field in connection_dialog.connection_parameter_edit_dictionary.values(): + edit_field.setText("42") + + # After setting values to every edit field, there should not be an empty parameter edit field. + assert connection_dialog.check_for_empty_parameter_edit_fields() is False + + def test_check_for_valid_port(self): + """ + Test the function for checking for a valid port in the port line edit. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Get the port line edit. + port_line_edit = connection_dialog.connection_parameter_edit_dictionary["Port"] + + # Set a string, which can not be casted to an int, as text for the port. + port_line_edit.setText("This is a triumph. I'm making a note here, huge success.") + # The check for a valid port should be False. + assert connection_dialog.check_for_valid_port() is False + + # Set a string, which can be casted to an int, as text for the port. The int in this case is invalid. + port_line_edit.setText("-42") + # The check for a valid port should still be False. + assert connection_dialog.check_for_valid_port() is False + + # Set a valid and an int, which can be casted to int, as port. + port_line_edit.setText("42") + # The check for a valid port should now be True. + assert connection_dialog.check_for_valid_port() is True + + def test_check_for_changed_password(self): + """ + Test the method for checking for a changed password in the QLineEdit for the password compared to the password + in the password manager. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Define the name of the service. + service = "Pygadmin" + # Define the identifier for the password + password_identifier = "testuser@random:5432" + # Choose a password. + password = "unsafe" + # Set the password in the keyring. + keyring.set_password(service, password_identifier, password) + + # Get the line edit for the password. + password_line_edit = connection_dialog.connection_parameter_edit_dictionary["Password"] + # Set the currently saved password as text. + password_line_edit.setText(password) + # The function for checking for a changed password should now be False. + assert connection_dialog.check_for_changed_password(password_identifier) is False + + # Change the text in the password line edit to another password. + password_line_edit.setText("unsafe again") + # Now there should be a changed password compared to the password manager, so the result should be True. + assert connection_dialog.check_for_changed_password(password_identifier) is True + + # Clean up, so the password identifier and the password for testing are not a part of the password manager. + keyring.delete_password(service, password_identifier) + + def test_set_password_with_identifier(self): + """ + Test the method for setting a password with its identifier. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Define the identifier for the password + password_identifier = "testuser@random:5432" + + # Get the password line edit. + password_line_edit = connection_dialog.connection_parameter_edit_dictionary["Password"] + # Set a text as password in the password line edit. + password_line_edit.setText("unsafe") + # The function for setting the password with its identifier should be True. + assert connection_dialog.set_password_with_its_identifier(password_identifier) is True + + # Clean up, so the password identifier and the password for testing are not a part of the password manager. + keyring.delete_password("Pygadmin", password_identifier) + + def test_valid_find_occurrence_in_list_widget_and_select_item(self): + """ + Test the method for finding the occurrence of an item in a list widget. The item should be selected after the + call of the method. Use valid connection parameters, which are also part of the connection store. + """ + + # Define a connection dictionary for saving the connection data in the connection store and creating a + # connection identifier later, so the connection is part of the list widget and can be selected. + connection_dictionary = {"Host": "random", + "Username": "testuser", + "Port": 5432, + "Database": "postgres"} + + # Save the connection in the connection store. + global_connection_store.save_connection_parameters_in_yaml_file(connection_dictionary) + # Save the current connections in the connection store in the yaml file. + global_connection_store.commit_current_list_to_yaml() + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Define a connection identifier for selecting it in the list widget. + connection_identifier = "{}@{}:{}/{}".format(connection_dictionary["Username"], connection_dictionary["Host"], + connection_dictionary["Port"], connection_dictionary["Database"]) + + # Get the item related to the connection identifier. + item = connection_dialog.find_occurrence_in_list_widget_and_select_item(connection_identifier) + + # The item should be a QListWidgetItem. + assert isinstance(item, QListWidgetItem) + # The text of the item should be the connection identifier. + assert item.text() == connection_identifier + + # Get the list of selected items. + selected_items = connection_dialog.connection_parameters_list_widget.selectedItems() + # Only one item should be selected, so the list should contain only one element. + assert len(selected_items) == 1 + + # Get the selected item. + selected_item = selected_items[0] + # The selected item should be the item, which is returned by the function. + assert selected_item == item + + # Clean up, so the test connection is no longer part of the connection store. + global_connection_store.delete_connection(connection_dictionary) + global_connection_store.commit_current_list_to_yaml() + + def test_invalid_find_occurrence_in_list_widget_and_select_item(self): + """ + Test the method for finding the occurrence of an item in a list widget. Use invalid connection parameters for + testing the error case, so the connection identifier is not based on existing connection parameters in the + connection store. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Get a non existing item in the list widget. + item = connection_dialog.find_occurrence_in_list_widget_and_select_item(None) + # The item should be None. + assert item is None + + # Check for the selected items in the list widget. + selected_items = connection_dialog.connection_parameters_list_widget.selectedItems() + # The list of selected items should be empty. + assert selected_items == [] + + def test_get_all_item_texts_in_list_widget(self): + """ + Test the method for getting all the texts of the items/connection identifiers in the list widget. + """ + + # Create a list for storing all connection identifiers. + connection_identifiers = [] + + # Use every dictionary of a connection in the connection store to create an identifier for the connection. + for connection_dictionary in global_connection_store.connection_parameters_yaml: + # Create the connection identifier. + identifier = "{}@{}:{}/{}".format(connection_dictionary["Username"], connection_dictionary["Host"], + connection_dictionary["Port"], connection_dictionary["Database"]) + + # Append the identifier to the list of connection identifiers. + connection_identifiers.append(identifier) + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + # Get all item texts. + item_texts = connection_dialog.get_all_item_texts_in_list_widget() + + # Iterate over every identifier for checking its existence in the item texts. + for identifier in connection_identifiers: + # The identifier should be part of the item texts. + assert identifier in item_texts + + def test_method_for_testing_database_connection(self): + """ + Test the method for testing for a valid database connection based on the current text in the QLineEdits. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # The text of the connection status label should be "Not tested yet", because a connection is not tested. + assert connection_dialog.test_given_connection_status_label.text() == "Not tested yet" + + # Set valid connection parameters for testing a connection. + connection_dialog.connection_parameter_edit_dictionary["Host"].setText("localhost") + connection_dialog.connection_parameter_edit_dictionary["Username"].setText("testuser") + connection_dialog.connection_parameter_edit_dictionary["Database"].setText("postgres") + connection_dialog.connection_parameter_edit_dictionary["Port"].setText("5432") + # Test the connection with the method. + connection_dialog.test_current_database_connection() + # The label should now show a valid database connection, because valid parameters are used. + assert connection_dialog.test_given_connection_status_label.text() == "Connection Valid" + + # Set a new text as host, so after a new test, the connection is invalid. + connection_dialog.connection_parameter_edit_dictionary["Host"].setText("localhorst") + # After changing the text, the label should switch back to "Not tested yet" + assert connection_dialog.test_given_connection_status_label.text() == "Not tested yet" + + # Test the database connection now. + connection_dialog.test_current_database_connection() + # The label should now show an invalid connection. + assert connection_dialog.test_given_connection_status_label.text() == "Connection Invalid" + + def test_insert_parameters_in_edit_fields(self): + """ + Test the method for inserting parameters in the QLineEdit fields based on the signal for a change in the + selection of the list widget. + """ + + # Define a connection dictionary for saving the connection data in the connection store and creating a + # connection identifier later, so the connection is part of the list widget and can be selected. + connection_dictionary = {"Host": "random", + "Username": "testuser", + "Port": 5432, + "Database": "postgres"} + + # Save the connection in the connection store. + global_connection_store.save_connection_parameters_in_yaml_file(connection_dictionary) + # Save the current connections in the connection store in the yaml file. + global_connection_store.commit_current_list_to_yaml() + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Define a connection identifier for selecting it in the list widget. + connection_identifier = "{}@{}:{}/{}".format(connection_dictionary["Username"], connection_dictionary["Host"], + connection_dictionary["Port"], connection_dictionary["Database"]) + + # Select the item based on the connection identifier in the list widget. + connection_dialog.find_occurrence_in_list_widget_and_select_item(connection_identifier) + + # Check the correct content of the QLineEdit fields. They should contain the parameters of the connection + # dictionary. + assert connection_dictionary["Host"] == connection_dialog.connection_parameter_edit_dictionary["Host"].text() + assert connection_dictionary["Username"] \ + == connection_dialog.connection_parameter_edit_dictionary["Username"].text() + # Cast the port to string, because it is saved in the dictionary as integer. + assert str(connection_dictionary["Port"]) \ + == connection_dialog.connection_parameter_edit_dictionary["Port"].text() + assert connection_dictionary["Database"] \ + == connection_dialog.connection_parameter_edit_dictionary["Database"].text() + + # Clean up, so the test connection is no longer part of the connection store. + global_connection_store.delete_connection(connection_dictionary) + global_connection_store.commit_current_list_to_yaml() + + def test_get_selected_connection(self): + """ + Test the function for getting the selected connection, which returns a boolean and sets a result as an + attribute. + """ + + # Define a connection dictionary for saving the connection data in the connection store and creating a + # connection identifier later, so the connection is part of the list widget and can be selected. + connection_dictionary = {"Host": "random", + "Username": "testuser", + "Port": 5432, + "Database": "postgres"} + + # Save the connection in the connection store. + global_connection_store.save_connection_parameters_in_yaml_file(connection_dictionary) + # Save the current connections in the connection store in the yaml file. + global_connection_store.commit_current_list_to_yaml() + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # A selected connection is missing, so the method should return False. + assert connection_dialog.get_selected_connection() is False + + # Define a connection identifier for selecting it in the list widget. + connection_identifier = "{}@{}:{}/{}".format(connection_dictionary["Username"], connection_dictionary["Host"], + connection_dictionary["Port"], connection_dictionary["Database"]) + + # Select the item related to the connection identifier. + connection_dialog.find_occurrence_in_list_widget_and_select_item(connection_identifier) + + # Now the method should return True, because an identifier is selected. + assert connection_dialog.get_selected_connection() is True + # The dictionary of the selected connection should be the pre-defined connection dictionary. + assert connection_dialog.selected_connection_parameters_dictionary == connection_dictionary + + # Clean up, so the test connection is no longer part of the connection store. + global_connection_store.delete_connection(connection_dictionary) + global_connection_store.commit_current_list_to_yaml() + + def test_valid_delete_selected_connection(self): + """ + Test the method for deleting the selected database connection with a saved database connection. + """ + + # Define a connection dictionary for saving the connection data in the connection store and creating a + # connection identifier later, so the connection is part of the list widget and can be selected. + connection_dictionary = {"Host": "random", + "Username": "testuser", + "Port": 5432, + "Database": "postgres"} + + # Define a password identifier for setting a password, so it can be deleted. + password_identifier = "{}@{}:{}".format(connection_dictionary["Username"], connection_dictionary["Host"], + connection_dictionary["Port"]) + + keyring.set_password("Pygadmin", password_identifier, "test") + + # Save the connection in the connection store. + global_connection_store.save_connection_parameters_in_yaml_file(connection_dictionary) + # Save the current connections in the connection store in the yaml file. + global_connection_store.commit_current_list_to_yaml() + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Clean up, so the test connection is no longer part of the connection store. + global_connection_store.delete_connection(connection_dictionary) + global_connection_store.commit_current_list_to_yaml() + + # Define a connection identifier for selecting it in the list widget. + connection_identifier = "{}@{}:{}/{}".format(connection_dictionary["Username"], connection_dictionary["Host"], + connection_dictionary["Port"], connection_dictionary["Database"]) + + # Select the item related to the connection identifier. + connection_dialog.find_occurrence_in_list_widget_and_select_item(connection_identifier) + + # The deletion of the selected connection should return True. + assert connection_dialog.delete_selected_connection() is True + + def test_invalid_delete_selected_connection(self): + """ + Test the method for deleting the selected database connection with an unsaved database connection. + """ + + # Define a connection dictionary for saving the connection data in the connection store and creating a + # connection identifier later, so the connection is part of the list widget and can be selected. + connection_dictionary = {"Host": "random", + "Username": "testuser", + "Port": 5432, + "Database": "postgres"} + + # Save the connection in the connection store. + global_connection_store.save_connection_parameters_in_yaml_file(connection_dictionary) + # Save the current connections in the connection store in the yaml file. + global_connection_store.commit_current_list_to_yaml() + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Clean up, so the test connection is no longer part of the connection store. + global_connection_store.delete_connection(connection_dictionary) + global_connection_store.commit_current_list_to_yaml() + + # Define a connection identifier for selecting it in the list widget. + connection_identifier = "{}@{}:{}/{}".format(connection_dictionary["Username"], connection_dictionary["Host"], + connection_dictionary["Port"], connection_dictionary["Database"]) + + # Select the item related to the connection identifier. + connection_dialog.find_occurrence_in_list_widget_and_select_item(connection_identifier) + + # The deletion of the selected connection should not return True. + assert connection_dialog.delete_selected_connection() is not True + + def test_check_for_valid_timeout(self): + """ + Test the method for checking for a valid timeout. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Set the text to an empty string, so a cast to an integer is not possible. + connection_dialog.timeout_line_edit.setText("") + # The timeout should be invalid. + assert connection_dialog.check_for_valid_timeout() is False + + # Set the text to an invalid integer value. + connection_dialog.timeout_line_edit.setText("-42") + # The timeout should still be invalid. + assert connection_dialog.check_for_valid_timeout() is False + + # Set the text to a valid integer value. + connection_dialog.timeout_line_edit.setText("42") + # The result should now be True. + assert connection_dialog.check_for_valid_timeout() is True + + def test_port_checkbox(self): + """ + Test the correct behavior of the (de)activation of the checkbox for using the standard postgres port. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a dialog. + connection_dialog = ConnectionDialogWidget() + + # Get the use postgres port checkbox of the dialog. + postgres_port_checkbox = connection_dialog.use_postgres_port_checkbox + # Get the port line edit. + port_line_edit = connection_dialog.connection_parameter_edit_dictionary["Port"] + + # Set the checkbox unchecked. + postgres_port_checkbox.setChecked(False) + # Now the port line edit should be empty. + assert port_line_edit.text() == "" + + # Define a port text for further testing. + port_text = "1337" + # Set the text as current text of the port line edit field. + port_line_edit.setText(port_text) + + # Set the port checkbox to checked. + postgres_port_checkbox.setChecked(True) + # Now the port line edit should contain the standard postgres port as text. + assert port_line_edit.text() == "5432" + + # Set the port checkbox back to unchecked. + postgres_port_checkbox.setChecked(False) + # Now the checkbox should contain the port text, which has been set earlier. + assert port_line_edit.text() == port_text diff --git a/tests/test_connectionfactory.py b/tests/test_connectionfactory.py new file mode 100644 index 0000000..6023c37 --- /dev/null +++ b/tests/test_connectionfactory.py @@ -0,0 +1,172 @@ +import unittest + +import psycopg2 + +from pygadmin.connectionfactory import global_connection_factory + + +class TestConnectionFactoryMethods(unittest.TestCase): + """ + Test the methods and the functionality of the (global) connection factory. + """ + + def test_factory_structure(self): + """ + Check the data structure of the factory: The factory should use a dict for saving the internal connection data. + """ + + assert isinstance(global_connection_factory.connections_dictionary, dict) + + def test_valid_get_connection(self): + """ + Use valid connection parameters for getting a correct connection. + """ + + connection = global_connection_factory.get_database_connection("localhost", "testuser", "testdb", 5432, 10000) + + assert isinstance(connection, psycopg2.extensions.connection) + + def test_wrong_password_get_connection(self): + """ + Use a non existing user for testing a fail in the password storage, so the connection should be set to None. + """ + + connection = global_connection_factory.get_database_connection("localhost", "test", "testdb") + + assert connection is None + + def test_invalid_get_connection(self): + """ + Use invalid connection parameters with an invalid/a non existing database, so the connection should be set to + False. + """ + + connection = global_connection_factory.get_database_connection("localhost", "testuser", "test") + + assert connection is False + + def test_valid_get_parameters(self): + """ + Test the method for getting parameters based on a given database connection. Predefine a dictionary with + parameters, get the connection based on them and then, get the dictionary for the connection. + """ + + # Define database connection parameters in a dictionary. The structure is equivalent to the structure of the + # dictionary, which is returned by the function of the factory for a connection. + database_parameters = { + "host": "localhost", + "user": "testuser", + "database": "testdb", + "port": 5432, + } + + # Get a database connection. + connection = global_connection_factory.get_database_connection(database_parameters["host"], + database_parameters["user"], + database_parameters["database"], + database_parameters["port"]) + + # Get a dictionary based on the established connection. + factory_parameters = global_connection_factory.get_database_connection_parameters(connection) + + # The dictionary, which was used to create a connection, and the dictionary, which matches with the connection, + # should be equivalent. + assert database_parameters == factory_parameters + + def test_invalid_get_parameters(self): + """ + Test the method for getting connection parameters based on a connection with an invalid connection. + """ + + # Use None as database connection, which is obviously not a valid database connection. + factory_parameter = global_connection_factory.get_database_connection_parameters(None) + + # For an error case, the method should return None. + assert factory_parameter is None + + def test_valid_connection_test(self): + """ + Use the method of the factory for testing a database connection with valid database connection parameters. + """ + + # A password is required for testing. Because this is a pure test environment and the data is more or less + # random and the database is localhost, the password is hard coded and visible in this file. + connection_possible = global_connection_factory.test_parameters_for_database_connection("localhost", "testuser", + "testdb", "test1234") + + # A correct connection should return True. + assert connection_possible is True + + def test_invalid_connection_test(self): + """ + Use the method of the factory for testing a database connection with invalid database connection parameters. + """ + + # Use invalid database connection parameters with an incorrect password. + connection_possible = global_connection_factory.test_parameters_for_database_connection("localhost", "testuser", + "test", "test42") + + print(connection_possible) + + # An invalid connection should return False. + assert connection_possible is False + + def test_valid_close_connection(self): + """ + Test the correct close and delete mechanism for a database connection. + """ + + # Get a database connection. + connection = global_connection_factory.get_database_connection("localhost", "testuser", "testdb") + # Close the connection and get the boolean for closing. + connection_closed = global_connection_factory.close_and_remove_database_connection(connection) + + # Check the boolean for closing. + assert connection_closed is True + # Double check: Try to find the connection parameters, which are related to the database connection. They should + # be None for not found. + assert global_connection_factory.get_database_connection_parameters(connection) is None + + def test_invalid_close_connection(self): + """ + Test the close and delete mechanism for an invalid database connection. + """ + + # Try to close an invalid database connection. + connection_closed = global_connection_factory.close_and_remove_database_connection(None) + + # The result should be not True. + assert connection_closed is not True + + def test_connection_reestablish(self): + """ + Test the method for reestablishing a database connection. + """ + + # Define database connection parameters for establishing a connection. + database_parameters = { + "host": "localhost", + "user": "testuser", + "database": "testdb", + "port": 5432, + } + + # Get the first connection related to the given parameters. + connection = global_connection_factory.get_database_connection(database_parameters["host"], + database_parameters["user"], + database_parameters["database"], + database_parameters["port"]) + + # Use the database parameters for creating a new connection, which is the reestablished old connection. + new_connection = global_connection_factory.reestablish_terminated_connection(database_parameters) + + # The old connection should be closed, so the closed attribute should be 1. + assert connection.closed == 1 + # Try to get connection parameters related to the old connection. This should be None, because a match is not + # found. + assert global_connection_factory.get_database_connection_parameters(connection) is None + # The new connection should be open, so the attribute should be 0. + assert new_connection.closed == 0 + # Check for the related connection parameters, which should be the initial dictionary. + assert global_connection_factory.get_database_connection_parameters(new_connection) == database_parameters + diff --git a/tests/test_connectionstore.py b/tests/test_connectionstore.py new file mode 100644 index 0000000..9b09011 --- /dev/null +++ b/tests/test_connectionstore.py @@ -0,0 +1,251 @@ +import unittest +import os + +from pygadmin.connectionstore import global_connection_store + + +class TestConnectionStoreMethods(unittest.TestCase): + """ + Test the connection store with its method and its behavior. + """ + + def test_path_of_connection_file(self): + """ + Test the existence of the file for storing the connection parameters. + """ + + assert os.path.exists(global_connection_store.yaml_connection_parameters_file) + + def test_connection_list(self): + """ + Test the connection list for its correct instance, which is also an implicit test for its existence. + """ + + assert isinstance(global_connection_store.connection_parameters_yaml, list) + + def test_get_connection_parameters(self): + """ + Test the function for getting all connection parameters. + """ + + # Use the function for getting the list. + connection_parameter_list = global_connection_store.get_connection_parameters_from_yaml_file() + # Check, if the returned list is the one, which contains all connection parameters in the connection store. + assert connection_parameter_list == global_connection_store.connection_parameters_yaml + + def test_valid_save_connection_parameters(self): + """ + Test the function for saving connection parameters with valid paramters. + """ + + # Define valid parameters. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 5432} + + # Save the parameters and assume a result, which is True. + assert global_connection_store.save_connection_parameters_in_yaml_file(test_parameters) is True + + # Use a clean up with deleting the connection. + global_connection_store.delete_connection(test_parameters) + + def test_invalid_save_connection_parameters(self): + """ + Test the function for saving connection parameters with invalid parameters. + """ + + # Use an empty dictionary as invalid parameters. + assert global_connection_store.save_connection_parameters_in_yaml_file({}) is False + + def test_duplicate_check(self): + """ + Test the function for a duplicate check. + """ + + # Define test parameters. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 1337} + + # Save the parameters in the connection store and yaml file. + global_connection_store.save_connection_parameters_in_yaml_file(test_parameters) + + # Use the function for checking a duplicate with the test parameters again. + assert global_connection_store.check_parameter_for_duplicate(test_parameters) is True + + # Clean up. + global_connection_store.delete_connection(test_parameters) + + def test_valid_delete_connection(self): + """ + Test the function for deleting a connection with saving parameters first and then deleting them. + """ + + # Define test parameters. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 1337} + + # Save the parameters. + global_connection_store.save_connection_parameters_in_yaml_file(test_parameters) + + # Delete the parameters and assume a successful deletion. + assert global_connection_store.delete_connection(test_parameters) is True + + def test_invalid_delete_connection(self): + """ + Test the deletion of an invalid connection dictionary. + """ + + # Use an empty dictionary as invalid dictionary. + assert global_connection_store.delete_connection({}) is False + + def test_valid_change_connection(self): + """ + Test the change of a connection with a valid connection dictionary and a new dictionary with changed paramters. + """ + + # Define the first test parameters. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 1337} + + # Save the first parameters. + global_connection_store.save_connection_parameters_in_yaml_file(test_parameters) + + # Define changed parameters with a different port. + changed_test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 5432} + + # Test for the correct change of parameters. + assert global_connection_store.change_connection(test_parameters, changed_test_parameters) is True + + # Clean up. + global_connection_store.delete_connection(changed_test_parameters) + + def test_invalid_change_connection(self): + """ + Test the function for changing information about a connection with invalid/duplicate data. + """ + + # Define a dictionary with connection parameters. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 1337} + + # Save the connection. + global_connection_store.save_connection_parameters_in_yaml_file(test_parameters) + + # Try to change the connection information, but this test should return False, because it is a duplicate. + assert global_connection_store.change_connection(test_parameters, test_parameters) is False + + # Clean up. + global_connection_store.delete_connection(test_parameters) + + def test_valid_check_key(self): + """ + Test the method for checking for the correct keys in the connection dictionary with valid data. + """ + + # Define a dictionary with test parameters. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 1337} + + # Assume a success. + assert global_connection_store.check_for_correct_keys_in_dictionary(test_parameters) is True + + def test_invalid_check_key(self): + """ + Test the method for checking for the correct keys in the dictionary with invalid data. + """ + + # Use an empty dictionary as invalid data. + assert global_connection_store.check_for_correct_keys_in_dictionary({}) is False + + def test_connection_parameter_number(self): + """ + Test the method for getting the number of connection parameters. + """ + + # Get the length of the list with parameters and compare them with the result of the method. + assert len(global_connection_store.connection_parameters_yaml) \ + == global_connection_store.get_number_of_connection_parameters() + + def test_valid_index_of_connection(self): + """ + Test the method for getting the index of a connection with a known dictionary and valid data. + """ + + # Get the current number of connection parameters before adding new data. + current_parameters_number = global_connection_store.get_number_of_connection_parameters() + + # Define a test dictionary. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 1337} + + # Save the test dictionary. + global_connection_store.save_connection_parameters_in_yaml_file(test_parameters) + + # Check for the correct index of the new connection. + assert global_connection_store.get_index_of_connection(test_parameters) == current_parameters_number + + # Clean up. + global_connection_store.delete_connection(test_parameters) + + def test_invalid_index_of_connection(self): + """ + Test the method for getting the index of a connection with invalid data and as a result an invalid index. + """ + + # Use an empty dictionary as invalid data. + assert global_connection_store.get_index_of_connection({}) is None + + def test_valid_connection_at_index(self): + """ + Test the method for getting an index at a specified position. + """ + + # Define test parameters. + test_parameters = {"Host": "testhost", + "Username": "testuser", + "Database": "testdb", + "Port": 1337} + + # Save the parameters. + global_connection_store.save_connection_parameters_in_yaml_file(test_parameters) + + # After a save of parameters, there has to be a connection at index 0. + connection_at_index = global_connection_store.get_connection_at_index(0) + + # Check the connection for the right instance. + assert isinstance(connection_at_index, dict) + # Check the connection for the correct keys, so the data structure is correct. + assert "Host" in connection_at_index + assert "Username" in connection_at_index + assert "Database" in connection_at_index + assert "Port" in connection_at_index + + # Clean up. + global_connection_store.delete_connection(test_parameters) + + def test_invalid_connection_at_index(self): + """ + Test the method for getting a connection at a given index with invalid data. + """ + + # Get the current number of connections. + current_connection_number = global_connection_store.get_number_of_connection_parameters() + # Check for the current number of connection as index (which is None, because the index starts at 0). + assert global_connection_store.get_connection_at_index(current_connection_number) is None + diff --git a/tests/test_database_dumper.py b/tests/test_database_dumper.py new file mode 100644 index 0000000..d720433 --- /dev/null +++ b/tests/test_database_dumper.py @@ -0,0 +1,277 @@ +import os +import re +import unittest + +from pygadmin.database_dumper import DatabaseDumper +from pygadmin.configurator import global_app_configurator + + +class TestDatabaseDumperMethods(unittest.TestCase): + """ + Test the class for dumping a database and its behavior. + """ + + def test_valid_database_dump(self): + """ + Use valid data for dumping a database and get the result. + """ + + # Use a valid connection and valid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 5432, "Table", "test") + # Get the result of a database dump. + result = database_dumper.dump_database() + + assert result is not None + + def test_invalid_database_dump(self): + """ + Use invalid data for dumping a database and check the invalid result. + """ + + # Use an invalid connection and invalid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 1337, "Table", "test") + # Get the result of a database dump. + result = database_dumper.dump_database() + + assert result is None + + def test_valid_create_pass_file(self): + """ + Test the method for creating a pass file with valid data. + """ + + # Define the relevant data for a dump. + user = "testuser" + database = "testdb" + host = "localhost" + port = 5432 + dump_information = "Table" + table_name = "test" + + # Define a password identifier, which is relevant for the creating of a pass file. + password_identifier = "{}@{}:{}".format(user, host, port) + + # Create a dumper. + database_dumper = DatabaseDumper(user, database, host, port, dump_information, table_name) + + # Get the file path and handler. + file_path, file_handler = database_dumper.create_pass_file(password_identifier) + + # Check the file path and the file handler for its existence. + assert os.path.exists(file_path) is True + assert os.path.exists(file_handler) is True + + def test_invalid_create_pass_file(self): + """ + Test the creation and usage of a pass file with invalid data, so the host, user and port cannot be found in the + password manager. The file creation and usage should function normally. + """ + + # Define the relevant data for a dump. + user = "testasdf" + database = "testdb" + host = "unknown" + port = 1337 + dump_information = "Table" + table_name = "test" + + # Define a password identifier, which is relevant for the creating of a pass file. + password_identifier = "{}@{}:{}".format(user, host, port) + + # Create a dumper. + database_dumper = DatabaseDumper(user, database, host, port, dump_information, table_name) + + # Get the file path and handler. + file_path, file_handler = database_dumper.create_pass_file(password_identifier) + + # Check the file path and the file handler for its existence, because they should also exist for invalid data. + assert os.path.exists(file_path) is True + assert os.path.exists(file_handler) is True + + def test_pg_dump_path(self): + """ + Test the functionality of the method for getting the pg dump path with also checking for the path in the global + app configurator. + """ + + # Get the pg dump path out of the global app configurator. + configurator_dump_path = global_app_configurator.get_single_configuration("pg_dump_path") + + # Create a dumper. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 5432, "Table", "test") + # Get the dump path as accessible attribute of the class. + database_dumper.get_pg_dump_path() + + # If a path in the configurator does exist, check for the correct set. + if configurator_dump_path is not None and os.path.exists(configurator_dump_path): + # The path in the configurator should be the pg dump path of the dumper. + assert database_dumper.pg_dump_path == configurator_dump_path + + # The path should not be a None, so it has been set. + assert database_dumper.pg_dump_path is not None + + def test_database_pg_dump_statement(self): + """ + Test the creation of a pg dump statement for a database. + """ + + # Define the relevant parameters as local variables. + user = "testuser" + database = "testdb" + host = "localhost" + port = 5432 + dump_information = "Database" + information_name = "testdb" + + # Create a dumper. + database_dumper = DatabaseDumper(user, database, host, port, dump_information, information_name) + # Get the dump path as attribute of the dumper, which is necessary for the dump statement. + database_dumper.get_pg_dump_path() + # Get the dump statement as attribute of the dumper. + database_dumper.get_pg_dump_statement() + + # The statement is a list, so get the list. + statement_list = database_dumper.pg_dump_statement + + # Check for the correct instance of the list. + assert isinstance(statement_list, list) + # Check for the relevant strings and statements as part of the statement list. + assert database_dumper.pg_dump_path in statement_list + assert "-T*" in statement_list + assert "--create" in statement_list + assert "--dbname=postgresql://{}@{}:{}/{}".format(user, host, port, database) in statement_list + + def test_table_pg_dump_statement(self): + """ + Test the creation of a pg dump statement for a table. + """ + + # Define the relevant parameters as local variables. + user = "testuser" + database = "testdb" + host = "localhost" + port = 5432 + dump_information = "Table" + information_name = "test" + + # Create a dumper. + database_dumper = DatabaseDumper(user, database, host, port, dump_information, information_name) + # Get the dump path as attribute of the dumper, which is necessary for the dump statement. + database_dumper.get_pg_dump_path() + # Get the dump statement as attribute of the dumper. + database_dumper.get_pg_dump_statement() + + # The statement is a list, so get the list. + statement_list = database_dumper.pg_dump_statement + + # Check for the correct instance of the list. + assert isinstance(statement_list, list) + # Check for the relevant strings and statements as part of the statement list. + assert database_dumper.pg_dump_path in statement_list + assert "--table={}".format(information_name) in statement_list + assert "--schema-only" in statement_list + assert "--dbname=postgresql://{}@{}:{}/{}".format(user, host, port, database) in statement_list + + def test_valid_database_dump_clean_result(self): + """ + Test the dump of a database with valid data and test the clean of the result. + """ + + # Use a valid connection and valid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 5432, "Table", "test") + # Get the result of a database dump. + result = database_dumper.dump_database_and_clean_result() + + # The result should be a list. + assert isinstance(result, list) + + def test_invalid_database_dump_clean_result(self): + """ + Test the dump of a database with invalid data and test the clean of the result. + """ + + # Use an invalid connection and invalid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 1337, "Table", "test") + # Get the result of a database dump. + result = database_dumper.dump_database() + + assert result is None + + def test_clean_database_result(self): + """ + Test the clean of the database result. + """ + + # Use a valid connection and valid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 5432, "Database", "testdb") + # Get the result of a database dump. + result = database_dumper.dump_database() + # Split the result into lines. + result_lines = result.stdout.split(os.linesep) + # Clean the result. + result_list = database_dumper.clean_database_result(result_lines) + + # Check for the correct clean, so only lines with CREATE or ALTER are valid. + for line in result_list: + assert re.search("CREATE|ALTER", line) is not None + + def test_clean_table_result(self): + """ + Test the clean of the table result. + """ + + # Use a valid connection and valid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 5432, "Table", "test") + # Get the result of a database dump. + result = database_dumper.dump_database() + # Split the result into lines. + result_lines = result.stdout.split(os.linesep) + # Clean the table result. + result_list = database_dumper.clean_table_result(result_lines) + # Define a bracket count, which counts opened and closed brackets. + bracket_count = 0 + + # Check every line for a CREATE or open/closed brackets. + for line in result_list: + assert re.search("CREATE", line) or bracket_count != 0 + for character in line: + if character == "(": + bracket_count += 1 + + if character == ")": + bracket_count -= 1 + + def test_clean_view_result(self): + """ + Test the clean of a view result. + """ + + # Use a valid connection and valid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 5432, "View", "testview") + # Get the result of a database dump. + result = database_dumper.dump_database() + # Split the result into lines. + result_lines = result.stdout.split(os.linesep) + # Clean the table result + result_list = database_dumper.clean_view_result(result_lines) + # This line is set to True after a CREATE VIEW and is set to False after a semicolon, which ends the command. + create_view = False + + # Check the result lines. + for line in result_list: + # The line should contain a CREATE VIEW or the create view should be in a previous line. + assert re.search("CREATE VIEW", line) or create_view is True + + create_view = True + + # Check for a semicolon to end the command. + for character in line: + if character == ";": + create_view = False + + def test_invalid_clean_result(self): + # Use a valid connection and valid dump data. + database_dumper = DatabaseDumper("testuser", "testdb", "localhost", 5432, "Database", "testdb") + + database_dumper.clean_database_result(None) + diff --git a/tests/test_database_query_executor.py b/tests/test_database_query_executor.py new file mode 100644 index 0000000..8efa6ef --- /dev/null +++ b/tests/test_database_query_executor.py @@ -0,0 +1,160 @@ +import unittest + +from pygadmin.database_query_executor import DatabaseQueryExecutor + +from pygadmin.connectionfactory import global_connection_factory + + +class TestDatabaseQueryExecutorMethods(unittest.TestCase): + """ + Test the methods and the functionality of the database query executor. + """ + + def test_creation(self): + """ + Test the creation of an object of the type database query executor. + """ + + executor = DatabaseQueryExecutor() + + # Check all relevant data as set to None. + assert executor.database_connection is None + assert executor.database_query is None + assert executor.database_query_parameter is None + + def test_valid_execute_query(self): + """ + Test the execution of a database query with valid input parameters. + """ + + # Create an executor. + executor = DatabaseQueryExecutor() + # Create a database connection. + database_connection = global_connection_factory.get_database_connection("localhost", "testuser", "testdb") + # Get the result and the message of a query. + result_data_list, query_message = executor.execute_query("SELECT * FROM test;", database_connection.cursor()) + + # The first result should be a list. + assert isinstance(result_data_list, list) + # The second result should be a message as string. + assert isinstance(query_message, str) + + def test_invalid_execute_query(self): + """ + Test the execution of a database query with invalid input parameters. + """ + + # Create an executor. + executor = DatabaseQueryExecutor() + # Create a database connection. + database_connection = global_connection_factory.get_database_connection("localhost", "testuser", "testdb") + + # This try statement is used for causing an error and catching it, because with an invalid table, there will + # be an error. assertRaise cannot be used, because the resulting error is a psycopg2 error and not a python + # exception. + try: + # Execute a query with an invalid/a undefined table. + executor.execute_query("SELECT * FROM testtt;", database_connection.cursor()) + # Assert something, which is wrong, so the test will fail, if this statement is reached. + assert 2 + 2 == 5 + + # Use the exception block for the assertion: Reaching this block, something, which is true, will be asserted, so + # the test will pass. + except Exception as error: + assert isinstance(error, Exception) + + def test_valid_connection_with_valid_parameters(self): + """ + Test the method is_connection_valid of the database query executor with valid database connection parameters and + a valid database connection. + """ + + # Create an executor. + executor = DatabaseQueryExecutor() + # Create a database connection. + database_connection = global_connection_factory.get_database_connection("localhost", "testuser", "testdb") + # Set the new database connection as connection for the executor. + executor.database_connection = database_connection + + # The fresh and new connection should be valid. + assert executor.is_connection_valid() is True + + # Close the database connection. + database_connection.close() + + # Now the connection after a close should be invalid. + assert executor.is_connection_valid() is False + + # Clean up: Remove the connection from the connection factory to prevent the further usage of the connection, + # which can cause errors or test failures. + global_connection_factory.close_and_remove_database_connection(database_connection) + + def test_valid_connection_with_invalid_parameters(self): + """ + Test the method is_connection_valid with invalid parameters and an invalid database connection. + """ + + # Create an executor. + executor = DatabaseQueryExecutor() + # Set the database connection of the executor to None. + executor.database_connection = None + + # The connection should be invalid. + assert executor.is_connection_valid() is False + + def test_reestablish_connection(self): + """ + Test the method for reestablishing a database connection. + """ + + # Create an executor. + executor = DatabaseQueryExecutor() + + database_connection = global_connection_factory.get_database_connection("localhost", "testuser", "testdb") + # Set the new database connection as connection for the executor. + executor.database_connection = database_connection + + # Check for a valid connection. + assert executor.is_connection_valid() is True + # Close the database connection. + database_connection.close() + # The connection should be invalid after a close. + assert executor.is_connection_valid() is False + # Reestablish the database connection. + executor.reestablish_connection() + # The connection should now be valid again. + assert executor.is_connection_valid() is True + + def test_valid_connection_check_and_reestablish(self): + """ + Test the function check_for_valid_connection_and_reestablish of the executor with a valid database connection. + """ + + # Create an executor. + executor = DatabaseQueryExecutor() + + database_connection = global_connection_factory.get_database_connection("localhost", "testuser", "testdb") + # Set the new database connection as connection for the executor. + executor.database_connection = database_connection + + # The new connection should return True, because the connection is functional. + assert executor.check_for_valid_connection_and_reestablish() is True + # Close the database connection. + database_connection.close() + # Check for a valid connection and reestablish: This should be True, because a connection can be reestablished. + assert executor.check_for_valid_connection_and_reestablish() is True + + def test_invalid_connection_check_and_reestablish(self): + """ + Test the function check_for_valid_connection_and_reestablish of the executor with an invalid database + connection. + """ + + # Create an executor. + executor = DatabaseQueryExecutor() + # Set the database connection of the executor to None, which is an invalid database connection. + executor.database_connection = None + + # The connection should be invalid. As a result, the connection cannot be reestablished. + assert executor.check_for_valid_connection_and_reestablish() is False + diff --git a/tests/test_dock.py b/tests/test_dock.py new file mode 100644 index 0000000..4e1edab --- /dev/null +++ b/tests/test_dock.py @@ -0,0 +1,26 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.dock import DockWidget +from pygadmin.widgets.tree import TreeWidget + + +class TestDockWidgetMethods(unittest.TestCase): + """ + Test the basic functionality of the dock widget. + """ + + def test_initial_attributes(self): + """ + Test the initial attributes of the dock. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create a dock widget. + dock_widget = DockWidget() + # The tree of the dock widget should be a TreeWidget + assert isinstance(dock_widget.tree, TreeWidget) + diff --git a/tests/test_editor.py b/tests/test_editor.py new file mode 100644 index 0000000..195f9dc --- /dev/null +++ b/tests/test_editor.py @@ -0,0 +1,279 @@ +import os +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.editor import EditorWidget +from pygadmin.models.tablemodel import TableModel +from pygadmin.database_query_executor import DatabaseQueryExecutor +from pygadmin.connectionfactory import global_connection_factory + + +class TestEditorWidgetMethods(unittest.TestCase): + """ + Test the functionality and methods of the editor widget. + """ + + def test_initial_attributes(self): + """ + Check for the correct existence and instance of the initial attributes of the editor. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # Test for the correct instance of the table model. + assert isinstance(editor_widget.table_model, TableModel) + # The data list of the table model should be empty at the start. + assert editor_widget.table_model.data_list == [] + # The current database connection should be None at the start. + assert editor_widget.current_database_connection is None + # The connection identifier should be None at at the start. + assert editor_widget.connection_identifier is None + # Test for the correct instance of the database query executor. + assert isinstance(editor_widget.database_query_executor, DatabaseQueryExecutor) + + def test_valid_set_connection_based_on_parameters(self): + """ + Test the method for setting connection parameters based on parameters in a dictionary with valid connection + parameters. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # Define a connection parameter dictionary. + connection_dictionary = {"host": "localhost", + "database": "postgres", + "port": 5432, + "user": "testuser"} + + # Get a database connection related to the parameters in the connection dictionary. + database_connection = global_connection_factory.get_database_connection(connection_dictionary["host"], + connection_dictionary["user"], + connection_dictionary["database"], + connection_dictionary["port"]) + + # Set the database connection of the editor based on the connection dictionary. + editor_widget.set_connection_based_on_parameters(connection_dictionary) + + # After a successful set of a database connection, the data list of the table model should still be empty and + # without an error message. + assert editor_widget.table_model.data_list == [] + # The database connection of the editor should be the connection based on the connection dictionary. + assert editor_widget.current_database_connection == database_connection + + def test_invalid_set_connection_based_on_parameters(self): + """ + Test the method for setting connection parameters based on parameters in a dictionary with invalid connection + parameters. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # Define a connection parameter dictionary. The host parameter is an invalid parameter. + connection_dictionary = {"host": "localhorst", + "database": "postgres", + "port": 5432, + "user": "testuser"} + + # Try to set the connection with the function for setting a database connection. + editor_widget.set_connection_based_on_parameters(connection_dictionary) + + # After a failed set of a database connection, there should be a warning in the table model. + assert editor_widget.table_model.data_list != [] + # The current database connection should be None after this kind of failure. + assert editor_widget.current_database_connection is None + + def test_refresh_table_model(self): + """ + Test the method for refreshing the table model of the editor. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # Define a list with new data. + new_data_list = [["column 1, column 2"], (1, "row A", "row B"), (2, "row C", "row D")] + # Use the function for refreshing the data list of the table model. + editor_widget.refresh_table_model(new_data_list) + # The data list of the table model should be the new data list. + assert editor_widget.table_model.data_list == new_data_list + + def test_save_current_statement_in_file(self): + """ + Test the function for saving the current statement in a file. User input is not necessary, because the file for + saving is predefined. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # Define a test text. + test_text = "This is a text for testing." + # Set the text as text of the query input editor for saving. + editor_widget.query_input_editor.setText(test_text) + + # Define a test path. + test_path = os.path.join(os.path.expanduser("~"), '.pygadmin_test') + # Create the test path. + os.mkdir(test_path) + # Define a test file. + test_file = os.path.join(test_path, "test_file.txt") + + # Use the test file as file for saving in the editor. + editor_widget.corresponding_saved_file = test_file + # Save the current statement and text of the query input editor in the pre defined test file. + editor_widget.save_current_statement_in_file() + + # Open the test file. + with open(test_file, "r") as test_file: + # Get the text of the file, which should be the currently saved text. + saved_editor_text = test_file.read() + + # Remove the file and the path as a clean up. + os.remove(test_file.name) + os.rmdir(test_path) + + # Check the text: The inserted text in the editor should be the text, which is saved in the file. + assert test_text == saved_editor_text + + def test_is_editor_empty(self): + """ + Test the method for an editor check: Is the editor empty or not? + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # After creating the widget, it should be empty. + assert editor_widget.is_editor_empty() is True + + # Set a text to the editor, so it is not empty anymore. + editor_widget.query_input_editor.setText("Test Text") + # The method should now return False. + assert editor_widget.is_editor_empty() is False + + # Set also an title to the editor widget. + editor_widget.setWindowTitle("Test Title") + # The method should still return False, because the editor is not empty. + assert editor_widget.is_editor_empty() is False + + # Set the text of the editor back to an empty string. + editor_widget.query_input_editor.setText("") + # Set the text to a string with content. + editor_widget.setWindowTitle("Test Title") + # The editor widget should not be empty. + assert editor_widget.is_editor_empty() is False + + def test_get_connection_status_string_for_window_title(self): + """ + Test the method for determining the connection part of the window title based on the different kinds of + connections and their validity. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # After the creation of the editor widget, the connection status string should be an empty string. + assert editor_widget.get_connection_status_string_for_window_title() == "" + + # Set the database connection of the editor to False for simulating a failed database connection. + editor_widget.current_database_connection = False + # Now there should be an alert to a failed connection. + assert editor_widget.get_connection_status_string_for_window_title() == "Connection failed: None" + + # Simulate a valid database connection with setting it to True. + editor_widget.current_database_connection = True + # Define an identifier for the connection simulation. + test_identifier = "Test identifier" + # Set the test identifier as connection identifier of the editor. + editor_widget.connection_identifier = test_identifier + # The connection string for the window title should be the connection identifier for a valid database + # connection. + assert editor_widget.get_connection_status_string_for_window_title() == test_identifier + + def test_get_corresponding_file_name_string_for_window_title_and_description(self): + """ + Test the method for getting the name of the file save path for the editor to set the correct window title. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # After the creation and without a corresponding saved file, the method should return a tuple of empty strings. + assert editor_widget.get_corresponding_file_name_string_for_window_title_and_description() == ("", "") + + # Define a path for the test save file. + save_file_path = "test/testfile/" + # Define a name for the test save file. + save_file_name = "test.sql" + # Create a full path with path and name together. + full_save_file_path = "{}{}".format(save_file_path, save_file_name) + # Set the full path as corresponding saved file for simulating the behavior of a QFileDialog. + editor_widget.corresponding_saved_file = full_save_file_path + + # Now the output should be the name of the file and the full path. + assert editor_widget.get_corresponding_file_name_string_for_window_title_and_description() == ( + save_file_name, full_save_file_path) + + def test_get_file_save_status_string_for_window_title(self): + """ + Test the method for checking the current status of the text in the query input editor of the editor compared to + its last saved version. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # The text in the editor should empty, so there should not be a change. The file save string should be empty. + assert editor_widget.get_file_save_status_string_for_window_title() == "" + + # Set a text to the editor, so the initial text has changed. + editor_widget.query_input_editor.setText("Test text") + # After the text change, there should be the information about it in the file save string. + assert editor_widget.get_file_save_status_string_for_window_title() == " (*)" + + def test_get_query_in_input_editor(self): + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget. + editor_widget = EditorWidget() + + # Define a start text for testing, so this text can be chosen for a plain and simple selection later. + test_text_start = "Test" + # Define an end text for testing. + test_text_end = " Text" + # Put the two strings together as test text. + test_text = "{}{}".format(test_text_start, test_text_end) + # Set the test text as text of the query input editor. + editor_widget.query_input_editor.setText(test_text) + + # The query in the query input editor should be the test text. + assert editor_widget.get_query_in_input_editor() == test_text + + # Set the selection to the test text start. + editor_widget.query_input_editor.setSelection(0, 0, 0, len(test_text_start)) + # Now the function for getting the query should return the test text start. + assert editor_widget.get_query_in_input_editor() == test_text_start + diff --git a/tests/test_editor_appearance_settings.py b/tests/test_editor_appearance_settings.py new file mode 100644 index 0000000..533544a --- /dev/null +++ b/tests/test_editor_appearance_settings.py @@ -0,0 +1,110 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.editor_appearance_settings import EditorAppearanceSettingsDialog +from pygadmin.configurator import global_app_configurator + + +class TestEditorAppearanceSettingsDialogMethods(unittest.TestCase): + """ + Test the functionality and methods of the editor appearance settings dialog. + """ + + def test_initial_attributes(self): + """ + Test the existence and correct instance of some initial attributes. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create an editor appearance settings dialog. + settings_dialog = EditorAppearanceSettingsDialog() + + # Check for the dictionary with the GUI items. + assert isinstance(settings_dialog.color_items_dictionary, dict) + # Check for the dictionary with the currently existing color themes. + assert isinstance(settings_dialog.current_color_themes_dictionary, dict) + + def test_set_selected_item_in_list_widget(self): + """ + Test the method for selecting a theme with the given name. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create an editor appearance settings dialog. + settings_dialog = EditorAppearanceSettingsDialog() + + # The theme "Hack" should be available as hard coded theme in the global app configurator. + item_to_select = "Hack" + # Select the item in the settings dialog. + settings_dialog.set_selected_item_in_list_widget(item_to_select) + # Get the selected item out of the list of selected items. + selected_item = settings_dialog.current_themes_list_widget.selectedItems()[0] + # The item to selected and the text of the selected item should be the same. + assert item_to_select == selected_item.text() + + def test_get_selected_item_in_list_widget(self): + """ + Test the function for getting the selected item of the list widget as attribute of the dialog. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create an editor appearance settings dialog. + settings_dialog = EditorAppearanceSettingsDialog() + + # Clear the selection of the settings dialog. If there is a default theme, the default theme is selected. + settings_dialog.current_themes_list_widget.selectionModel().clearSelection() + + # After clearing the selection, the method for getting the selected item should return False, because there is + # no selected item. + assert settings_dialog.get_selected_item_in_list_widget() is False + # The selected list widget item should be None, because there is no selection. + assert settings_dialog.selected_list_widget_item is None + + # Choose an item for selecting. + item_to_select = "Hack" + # Set the selected item in the list widget. + settings_dialog.set_selected_item_in_list_widget(item_to_select) + + # Now there should be a selected item in the list widget, so the function returns True. + assert settings_dialog.get_selected_item_in_list_widget() is True + # The selected item in the list widget should be the item to select. + assert settings_dialog.selected_list_widget_item == item_to_select + + def test_save_changes_in_configuration_and_apply(self): + """ + Test the function for changing the current saves. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create an editor appearance settings dialog. + settings_dialog = EditorAppearanceSettingsDialog() + + # The function should return True for a successful save. + assert settings_dialog.save_changes_in_configuration_and_apply() is True + + def test_set_default_theme(self): + """ + Test the method for setting a new default theme. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create an editor appearance settings dialog. + settings_dialog = EditorAppearanceSettingsDialog() + + # Choose an item for selecting. + item_to_select = "Hack" + # Set the selected item in the list widget. + settings_dialog.set_selected_item_in_list_widget(item_to_select) + + # Set the default theme. The selected theme is now the default theme. + settings_dialog.set_default_theme() + + # The default theme in the settings dialog should be saved in the global app configurator. + assert settings_dialog.default_theme == global_app_configurator.get_single_configuration("color_theme") diff --git a/tests/test_lexer.py b/tests/test_lexer.py new file mode 100644 index 0000000..da59d61 --- /dev/null +++ b/tests/test_lexer.py @@ -0,0 +1,73 @@ +import unittest + +from PyQt5.QtGui import QColor + +from pygadmin.models.lexer import SQLLexer + + +class TestLexerMethods(unittest.TestCase): + """ + Use a class for testing the SQLLexer and its methods. + """ + + def test_lexer_color_parameters_dictionary(self): + """ + Check the color parameter dictionary of the lexer, which should be not empty. + """ + + lexer = SQLLexer(None) + + assert lexer.color_parameters_dictionary != {} + + def test_qcolor_lexer(self): + """ + Check the correct instance of the color in the color parameter dictionary of the lexer. + """ + + lexer = SQLLexer(None) + + for value in lexer.color_parameters_dictionary.values(): + assert isinstance(value, QColor) + + def test_correct_color_parameter_keys(self): + """ + Check for the right keys in the color parameter dictionary of the lexer. + """ + + lexer = SQLLexer(None) + # Define the relevant color keys. + color_keys = ["default_color", "default_paper_color", "keyword_color", "number_color", "other_keyword_color", + "apostrophe_color"] + + # Check every necessary key for its existence. + for color in color_keys: + assert lexer.color_parameters_dictionary[color] + + def test_set_color(self): + """ + Test the method of the lexer for setting a color defined by a color dictionary with the color keyword and a + QColor for testing the color set. + """ + + lexer = SQLLexer(None) + # Define a color dictionary with tests. + color_dictionary = {"default_color": QColor("ff0000ff"), + "default_paper_color": QColor("#ffffff00"), + "keyword_color": QColor("#ff00000f"), + "number_color": QColor("#ff000f0f"), + "other_keyword_color": QColor("#ff0f0f00"), + "apostrophe_color": QColor("#ff0f0000") + } + + # Set the colors in the lexer. + lexer.set_lexer_colors(color_dictionary) + + # Check, if every color has been set properly. + assert color_dictionary["default_color"].name() == lexer.defaultColor(10).name() + assert color_dictionary["default_paper_color"].name() == lexer.defaultPaper(0).name() + assert color_dictionary["default_paper_color"].name() == lexer.color(0).name() + assert color_dictionary["keyword_color"].name() == lexer.color(5).name() + assert color_dictionary["number_color"].name() == lexer.color(4).name() + assert color_dictionary["other_keyword_color"].name() == lexer.color(8).name() + assert color_dictionary["apostrophe_color"].name() == lexer.color(7).name() + diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 0000000..5aa8acd --- /dev/null +++ b/tests/test_main_window.py @@ -0,0 +1,206 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication, QMdiArea, QDockWidget, QMenuBar, QToolBar + +from pygadmin.widgets.main_window import MainWindow +from pygadmin.widgets.connection_dialog import ConnectionDialogWidget +from pygadmin.widgets.configuration_settings import ConfigurationSettingsDialog +from pygadmin.widgets.editor_appearance_settings import EditorAppearanceSettingsDialog +from pygadmin.widgets.version_information_dialog import VersionInformationDialog +from pygadmin.configurator import global_app_configurator + + +class TestMainWindowMethods(unittest.TestCase): + """ + Test the functionality and methods of the main window. + """ + + @staticmethod + def set_opening_connection_dialog_to_false(): + """ + Set the configuration for opening a connection dialog at the start of the application/main window to False, so + a connection dialog is not displayed. + """ + + global_app_configurator.set_single_configuration("open_connection_dialog_at_start", False) + + def test_initial_attributes(self): + """ + Test the existence and correct instance of some initial attributes. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # The MdiArea of the main window should be a QMdiArea. + assert isinstance(main_window.mdi_area, QMdiArea) + # The central widget should be the mdi area. + assert main_window.centralWidget() == main_window.mdi_area + # The sub window list of the mdi area should contain one item, because there is one editor at the start. + assert len(main_window.mdi_area.subWindowList()) == 1 + + # The dock widget should be a QDockWidget. + assert isinstance(main_window.dock_widget, QDockWidget) + + # The menu bar should be a QMenuBar. + assert isinstance(main_window.menu_bar, QMenuBar) + # The tool bar should be a QToolBar. + assert isinstance(main_window.tool_bar, QToolBar) + + # The status bar should show the message "Ready" at the start. + assert main_window.statusBar().currentMessage() == "Ready" + + def test_add_action_to_menu_bar(self): + """ + Test the correct appending of an action to the menu bar. In this case, the edit menu point is used. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # Define a name for the new action. + new_action_name = "test" + + # The name of the new action should not be the name of an existing action. + for action in main_window.edit_menu.actions(): + assert action.text() != new_action_name + + # Add the action with the new name and a simple test function to the menu bar. Without a menu, the edit menu is + # used. + main_window.add_action_to_menu_bar(new_action_name, lambda: print("test")) + + # Check for an action with the name of the new action. The result should be one item, because there is one item + # with the name of the new action: The new action. + matching_action = [action.text() for action in main_window.edit_menu.actions() + if action.text() == new_action_name] + + # The list should contain one element. + assert len(matching_action) == 1 + + def test_add_action_to_tool_bar(self): + """ + Test the correct appending of an action to the tool bar. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # Define a name for the new action. + new_action_name = "test" + + # The name of the new action should be unique, so there should not be an action with its text/name. + for action in main_window.tool_bar.actions(): + assert new_action_name != action.text() + + # Add the new action to the tool bar. The path for the icon is an invalid test path and the function is only a + # simple test function. + main_window.add_action_to_tool_bar(new_action_name, "test_path", lambda: print("test")) + + # Check for an action with the name of the new action. The result should be one item, because there is one item + # with the name of the new action: The new action. + matching_action = [action.text() for action in main_window.tool_bar.actions() + if action.text() == new_action_name] + + # The resulting list should contain one element. + assert len(matching_action) == 1 + + def test_activate_new_connection_dialog(self): + """ + Test the activation of a new connection dialog. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # Activate a new connection dialog. + main_window.activate_new_connection_dialog() + # The new connection dialog should be a connection dialog. + assert isinstance(main_window.new_connection_dialog, ConnectionDialogWidget) + # The new connection dialog should be visible, because it is active. + assert main_window.new_connection_dialog.isVisible() is True + + def test_activate_new_configuration_settings_dialog(self): + """ + Test the activation of a new configuration settings dialog. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # Activate a new configuration settings dialog. + main_window.activate_new_configuration_settings_dialog() + # The new dialog should have the correct type. + assert isinstance(main_window.configuration_settings_dialog, ConfigurationSettingsDialog) + # The dialog should be visible/active. + assert main_window.configuration_settings_dialog.isVisible() is True + + def test_activate_new_editor_appearance_dialog(self): + """" + Test the activation of a new editor appearance dialog. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # Activate a new editor appearance settings dialog. + main_window.activate_new_editor_appearance_dialog() + # The new dialog should have the correct type. + assert isinstance(main_window.editor_appearance_dialog, EditorAppearanceSettingsDialog) + # The dialog should be visible/active. + assert main_window.editor_appearance_dialog.isVisible() is True + + def test_show_status_bar_message(self): + """ + Test the function for showing a new status bar message. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # Define a new message for the status bar. + new_message = "test" + # Set the message in the status bar. + main_window.show_status_bar_message(new_message) + # The status bar message should be the new message. + assert main_window.statusBar().currentMessage() == new_message + + def test_show_version_information_dialog(self): + """" + Test the activation of a new version information dialog. + """ + + self.set_opening_connection_dialog_to_false() + # Create an app, because this is necessary for testing a QMainWindow. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + + # Show a new version information dialog. + main_window.show_version_information_dialog() + # The new dialog should have the correct type. + assert isinstance(main_window.version_information_dialog, VersionInformationDialog) + # The dialog should be visible/active. + assert main_window.version_information_dialog.isVisible() is True + diff --git a/tests/test_mdi_area.py b/tests/test_mdi_area.py new file mode 100644 index 0000000..d78b7f4 --- /dev/null +++ b/tests/test_mdi_area.py @@ -0,0 +1,167 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.mdi_area import MdiArea +from pygadmin.widgets.editor import EditorWidget + +from pygadmin.connectionfactory import global_connection_factory + + +class TestMdiAreaMethods(unittest.TestCase): + """ + Test the functionality and methods of Mdi area. + """ + + def test_initial_attributes(self): + """ + Test the existence and correct instance of some initial attributes. + """ + + # Create an app, because this is necessary for testing a QMdiArea. + app = QApplication(sys.argv) + # Create an MdiArea. + mdi_area = MdiArea() + + # The tabs in the MdiArea should be movable. + assert mdi_area.tabsMovable() is True + # The tabs in the MdiArea should be closable. + assert mdi_area.tabsClosable() is True + # The view mode should be 1, which is the tab view. + assert mdi_area.viewMode() == 1 + + def test_generate_editor_widget(self): + """ + Test the correct generating process of an editor in the MdiArea. + """ + + # Create an app, because this is necessary for testing a QMdiArea. + app = QApplication(sys.argv) + # Create an MdiArea. + mdi_area = MdiArea() + + # Generate an editor, which is also the return value of the function. + generated_editor = mdi_area.generate_editor_tab() + # The generated editor should be an editor widget. + assert isinstance(generated_editor, EditorWidget) + + # Get the widgets of the sub windows of the MdiArea as list. + widget_list = [sub_window.widget() for sub_window in mdi_area.subWindowList()] + # The generated editor should be in the widget list. + assert generated_editor in widget_list + + def test_change_current_sub_window_and_connection_parameters(self): + """ + Test the method for setting new database connection parameters in case of an editor change in the MdiArea. + """ + + # Create an app, because this is necessary for testing a QMdiArea. + app = QApplication(sys.argv) + # Create an MdiArea. + mdi_area = MdiArea() + + # Generate an editor, so there is a current sub window. + generated_editor = mdi_area.generate_editor_tab() + # Define a dictionary with connection parameters. + connection_parameters = {"host": "localhost", + "user": "testuser", + "database": "testdb", + "port": 5432} + + # Set the connection parameters. + mdi_area.change_current_sub_window_and_connection_parameters(connection_parameters) + # Get the current connection of the editor. + editor_connection = generated_editor.current_database_connection + # Get the connection dictionary related to the connection of the editor. + editor_connection_parameters = global_connection_factory.get_database_connection_parameters(editor_connection) + # The connection parameters of the editor should be identical to the initial connection parameters. + assert editor_connection_parameters == connection_parameters + + def test_determine_current_editor_widget(self): + """ + Test the method for determining the current editor widget. + """ + + # Create an app, because this is necessary for testing a QMdiArea. + app = QApplication(sys.argv) + # Create an MdiArea. + mdi_area = MdiArea() + + # Right after the initialization and without any editor widget, the function for determining the current editor + # widget should return None. + assert mdi_area.determine_current_editor_widget() is None + + # Generate a new editor tab. + generated_editor = mdi_area.generate_editor_tab() + # The current editor widget should be the freshly generated editor. + assert mdi_area.determine_current_editor_widget() == generated_editor + + def test_determine_empty_editor_widget_with_connection(self): + """ + Test the method for getting an empty editor widget with a given database connection. + """ + + # Create an app, because this is necessary for testing a QMdiArea. + app = QApplication(sys.argv) + # Create an MdiArea. + mdi_area = MdiArea() + + # Generate a new editor tab. + editor_widget = mdi_area.generate_editor_tab() + + # Define a dictionary with connection parameters for the freshly generated editor widget. + first_connection_parameters = {"host": "localhost", + "user": "testuser", + "database": "testdb", + "port": 5432} + + # Get a connection based on the dictionary with connection parameters. + connection = global_connection_factory.get_database_connection(first_connection_parameters["host"], + first_connection_parameters["user"], + first_connection_parameters["database"], + first_connection_parameters["port"]) + + # Set the new connection as current connection of the generated editor widget. + editor_widget.current_database_connection = connection + # The editor with the database connection should be the generated editor_widget. + assert mdi_area.determine_empty_editor_widget_with_connection(first_connection_parameters) == editor_widget + + # Define a second dictionary with different connection parameters. + second_connection_parameters = {"host": "localhost", + "user": "testuser", + "database": "postgres", + "port": 5432} + + # A new generated widget with new connection parameters should not be the first generated editor widget. + assert mdi_area.determine_empty_editor_widget_with_connection(second_connection_parameters) != editor_widget + + def test_determine_next_empty_editor_widget(self): + """ + Test the method for determining the next empty editor widget. + """ + + # Create an app, because this is necessary for testing a QMdiArea. + app = QApplication(sys.argv) + # Create an MdiArea. + mdi_area = MdiArea() + + # After the initialization and without any editor widgets, the function should return None, + assert mdi_area.determine_next_empty_editor_widget() is None + + # Generate an editor widget. + first_editor_widget = mdi_area.generate_editor_tab() + # The generated widget should be an empty editor widget. + assert mdi_area.determine_next_empty_editor_widget() == first_editor_widget + + # Set a text to the editor widget, so it is not empty anymore. + first_editor_widget.query_input_editor.setText("Not Empty") + # There is only one widget and this widget is not empty, so the function for determining an empty editor widget + # should return None. + assert mdi_area.determine_next_empty_editor_widget() is None + + # Generate a new editor tab. + second_editor_widget = mdi_area.generate_editor_tab() + # The freshly generated widget should be the next empty editor widget. + assert mdi_area.determine_next_empty_editor_widget() == second_editor_widget + diff --git a/tests/test_node_create_information.py b/tests/test_node_create_information.py new file mode 100644 index 0000000..c3955d5 --- /dev/null +++ b/tests/test_node_create_information.py @@ -0,0 +1,125 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.node_create_information import NodeCreateInformationDialog +from pygadmin.models.treemodel import DatabaseNode, TableNode, ViewNode + + +class TestNodeCreateInformationMethods(unittest.TestCase): + """ + Test the functionality and methods of node create information dialog. + """ + + def test_dialog_without_node(self): + """ + Test the reaction of the dialog to the input of None instead of a node. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create an information dialog without a node. + node_information_dialog = NodeCreateInformationDialog(None) + + # The window title should assume an error. + assert node_information_dialog.windowTitle() == "Node Input Error" + + def test_dialog_with_valid_database_node(self): + """ + Test the node information dialog with a database node. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a database node for the dialog. + database_node = DatabaseNode("testdb", "localhost", "testuser", "testdb", 5432, 10000) + # Create an information dialog with the database node. + node_information_dialog = NodeCreateInformationDialog(database_node) + # The selected node should be the database node. + assert node_information_dialog.selected_node == database_node + + # Get the create statement of the node. + create_statement = node_information_dialog.get_node_create_statement() + self.check_create_statement(create_statement) + + def test_dialog_with_valid_table_node(self): + """ + Test the node information dialog with a table node. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a table node for the dialog. + table_node = TableNode("test", "localhost", "testuser", "testdb", 5432, 10000) + # Create an information dialog with the table node. + node_information_dialog = NodeCreateInformationDialog(table_node) + # The selected node should be the table node. + assert node_information_dialog.selected_node == table_node + + # Get the create statement of the node. + create_statement = node_information_dialog.get_node_create_statement() + self.check_create_statement(create_statement) + + def test_dialog_with_valid_view_node(self): + """ + Test the node information dialog with a view node. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a view node for the dialog. + view_node = ViewNode("testview", "localhost", "testuser", "testdb", 5432, 10000) + # Create an information dialog with view node. + node_information_dialog = NodeCreateInformationDialog(view_node) + # The selected node should be the view node. + assert node_information_dialog.selected_node == view_node + + # Get the create statement of the node. + create_statement = node_information_dialog.get_node_create_statement() + self.check_create_statement(create_statement) + + def test_dialog_with_invalid_node(self): + """ + Test the dialog with an invalid database node as error case. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a database node with invalid connection parameters. In this case, the port is invalid. + database_node = DatabaseNode("testdb", "localhost", "testuser", "testdb", 1337, 10000) + node_information_dialog = NodeCreateInformationDialog(database_node) + # The selected node should be the database node. + assert node_information_dialog.selected_node == database_node + # Get the create statement of the node. + create_statement = node_information_dialog.get_node_create_statement() + self.check_create_statement(create_statement) + + @staticmethod + def check_create_statement(create_statement): + """ + Define a method for checking the create statement of a node. The statement should contain at least one specified + word in a list. + """ + + # The create statement should not be empty. + assert create_statement != "" + + # Define a list with possible sub strings for a create statement. The string "failed" is in the list for the + # error case. + statement_can_contain_list = ["\nWITH", "\nENCODING", "\nLC_COLLATE", "\nLC_CTYPE", "\nLC_TYPE", "\nALTER", + "CREATE", "failed"] + + # Define a boolean for checking the existence of a word in the list. This boolean is used in the for loop and + # after the loop, it should be True. + contains_string = False + + # Check for the existence of at least one word of the list. + for possible_string in statement_can_contain_list: + # Check for the string, which could be part of the create statement. + if possible_string in create_statement: + # Change the check boolean to True. + contains_string = True + + # After iterating over the list with words, the boolean for checking should be True. + assert contains_string is True diff --git a/tests/test_permission_information.py b/tests/test_permission_information.py new file mode 100644 index 0000000..b54fae3 --- /dev/null +++ b/tests/test_permission_information.py @@ -0,0 +1,32 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.permission_information import PermissionInformationDialog +from pygadmin.models.treemodel import DatabaseNode, TableNode + + +class TestPermissionInformationMethods(unittest.TestCase): + """ + Test the functionality and methods of node create information dialog. + """ + + def test_dialog_without_node(self): + """ + Test the reaction of the dialog to the input of None instead of a node. + """ + + app = QApplication(sys.argv) + permission_information_dialog = PermissionInformationDialog(None) + assert permission_information_dialog.windowTitle() == "Node Input Error" + + def test_dialog_with_node(self): + """ + Test the dialog with an existing node. + """ + + app = QApplication(sys.argv) + database_node = DatabaseNode("testdb", "localhost", "testuser", "testdb", 5432, 10000) + permission_information_dialog = PermissionInformationDialog(database_node) + assert permission_information_dialog.selected_node == database_node diff --git a/tests/test_search_replace_widget.py b/tests/test_search_replace_widget.py new file mode 100644 index 0000000..29fd3d2 --- /dev/null +++ b/tests/test_search_replace_widget.py @@ -0,0 +1,109 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.editor import EditorWidget +from pygadmin.widgets.search_replace_widget import SearchReplaceWidget + + +class TestSearchPlaceWidgetMethods(unittest.TestCase): + """ + Test the functionality and methods of the search replace widget. + """ + + def test_initial_attributes(self): + """ + Check for the correct existence and instance of the initial attributes of the search replace widget. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget as parent for the search replace dialog. + editor_widget = EditorWidget() + # Create a search replace dialog with the editor widget as a parent + search_replace_widget = SearchReplaceWidget(editor_widget) + + # The dictionaries with the search and replace items should exist as dictionaries. + assert isinstance(search_replace_widget.search_items, dict) + assert isinstance(search_replace_widget.replace_items, dict) + + def test_hide_replace_components(self): + """ + Test the functionality of the method for hiding the replace components. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget as parent for the search replace dialog. + editor_widget = EditorWidget() + # Create a search replace dialog with the editor widget as a parent + search_replace_widget = SearchReplaceWidget(editor_widget) + # Hide the replace components. + search_replace_widget.hide_replace_components() + + # Check every component for hiding. + for replace_item in search_replace_widget.replace_items.values(): + # The replace item should not be visible. + assert replace_item.isVisible() is False + + def test_show_replace_components(self): + """ + Test the method for showing the replace components. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget as parent for the search replace dialog. + editor_widget = EditorWidget() + # Create a search replace dialog with the editor widget as a parent + search_replace_widget = SearchReplaceWidget(editor_widget) + # Show the replace components. + search_replace_widget.show_replace_components() + + # Check every component for showing. + for replace_item in search_replace_widget.replace_items.values(): + # The replace item should be visible. + assert replace_item.isVisible() is True + + def test_set_search_text(self): + """ + Test the method for setting the text of the search line edit. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget as parent for the search replace dialog. + editor_widget = EditorWidget() + # Create a search replace dialog with the editor widget as a parent + search_replace_widget = SearchReplaceWidget(editor_widget) + # Define a text for testing. + test_text = "Test" + # Set the test text. + search_replace_widget.set_search_text(test_text) + # The test text should be the text of the line edit. + assert search_replace_widget.search_items["search_line_edit"].text() == test_text + + def test_get_search_and_replace_text(self): + """ + Test the method for getting the current text of the search and the replace line edit. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create an editor widget as parent for the search replace dialog. + editor_widget = EditorWidget() + # Create a search replace dialog with the editor widget as a parent + search_replace_widget = SearchReplaceWidget(editor_widget) + # Define a text for testing. + test_text = "Test" + + # Set the text for testing as text of the search line edit. + search_replace_widget.search_items["search_line_edit"].setText(test_text) + # Now the method for getting the search text should return the test text. + assert search_replace_widget.get_search_text() == test_text + + # Set the text for testing as text of the replace line edit. + search_replace_widget.replace_items["replace_line_edit"].setText(test_text) + # Now the method for getting the replace text should return the test text. + assert search_replace_widget.get_replace_text() == test_text diff --git a/tests/test_start.py b/tests/test_start.py new file mode 100644 index 0000000..a9dfb91 --- /dev/null +++ b/tests/test_start.py @@ -0,0 +1,47 @@ +import unittest +import signal +import sys + +import faulthandler + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.main_window import MainWindow + +faulthandler.enable() + + +class TestStartMethods(unittest.TestCase): + """ + Test the correct start of the application. + """ + + def test_start(self): + """ + Simulate the start of the application like in the main function in __init__.py, but without app.exec(), so a + user does not have to end the application manually. + """ + + # Define easier ending of the application. + signal.signal(signal.SIGINT, signal.SIG_DFL) + # Define an app. + app = QApplication(sys.argv) + # Create a main window. + main_window = MainWindow() + # Show the main window, but the application just ends. + main_window.show() + + def test_start_multiple_times(self): + """ + Simulate the start multiple times. + """ + + for i in range(500): + print("Start test {}".format(i)) + self.test_start() + print("Still alive") + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_start_progress_dialog.py b/tests/test_start_progress_dialog.py new file mode 100644 index 0000000..20e4769 --- /dev/null +++ b/tests/test_start_progress_dialog.py @@ -0,0 +1,102 @@ +import sys +import unittest + +from PyQt5.QtCore import QBasicTimer +from PyQt5.QtWidgets import QApplication, QProgressBar, QLabel + +from pygadmin.widgets.start_progress_dialog import StartProgressDialog +from pygadmin.connectionstore import global_connection_store + + +class TestStartProgressDialogMethods(unittest.TestCase): + """ + Test the functionality and behavior of the start progress dialog. + """ + + def test_initial_attributes(self): + """ + Test the initial attributes of the dialog. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a start progress dialog. + start_progress_dialog = StartProgressDialog() + + # There should be a progress bar, because this is the whole idea of having a start progress dialog. + assert isinstance(start_progress_dialog.progress_bar, QProgressBar) + # There should also be a description label, informing about the current process. + assert isinstance(start_progress_dialog.description_label, QLabel) + # A timer is required for the functionality of the progress bar. + assert isinstance(start_progress_dialog.timer, QBasicTimer) + # The step at the beginning should be 0. + assert start_progress_dialog.float_step == 0 + + def test_progress_bar_with_zero_connections(self): + """ + Test the functionality of the progress bar without connections in the connection store. + """ + + # Make a copy of the current connection list for storing it. + connection_list = global_connection_store.get_connection_parameters_from_yaml_file() + # Set the current list to an empty list. + global_connection_store.connection_parameters_yaml = [] + # Delete all connections and store the empty list. + global_connection_store.commit_current_list_to_yaml() + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a start progress dialog. + start_progress_dialog = StartProgressDialog() + + # Start the progress bar without connections. + start_progress_dialog.start_progress_bar() + + # Now the float step and the step size should be 100. + assert start_progress_dialog.float_step == 100 + assert start_progress_dialog.step_size == 100 + + # Restore the connection list. + global_connection_store.connection_parameters_yaml = connection_list + global_connection_store.commit_current_list_to_yaml() + + def test_progress_bar_with_connections(self): + """ + Test the progress bar with existing connections. + """ + + # Define one dictionary for one connection. + first_connection_dictionary = {"Host": "testhost", + "Username": "testuser", + "Database": "postgres", + "Port": 5432} + + # Define another dictionary for a second connection. + second_connection_dictionary = {"Host": "anothertesthost", + "Username": "testuser", + "Database": "postgres", + "Port": 5432} + + # Load the current connections. + global_connection_store.get_connection_parameters_from_yaml_file() + # Insert the pre-defined dictionaries to the connection store, so there are at least two dictionaries. + global_connection_store.save_connection_parameters_in_yaml_file(first_connection_dictionary) + global_connection_store.save_connection_parameters_in_yaml_file(second_connection_dictionary) + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a start progress dialog. + start_progress_dialog = StartProgressDialog() + + # Start the progress bar. + start_progress_dialog.start_progress_bar() + + # Now the step should be start at 0. + assert start_progress_dialog.float_step == 0 + # The step size should be smaller than 100, because there are at least two connection parameters in the global + # connection store. + assert start_progress_dialog.step_size < 100 + + # Clean up, delete the two created connections. + global_connection_store.delete_connection(first_connection_dictionary) + global_connection_store.delete_connection(second_connection_dictionary) diff --git a/tests/test_table_edit.py b/tests/test_table_edit.py new file mode 100644 index 0000000..8459986 --- /dev/null +++ b/tests/test_table_edit.py @@ -0,0 +1,146 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.models.treemodel import TableNode +from pygadmin.widgets.table_edit import TableEditDialog +from pygadmin.configurator import global_app_configurator + + +class TestTableEditDialogMethods(unittest.TestCase): + """ + Test the basic methods and the behavior of the table edit dialog. + """ + + def test_dialog_without_node(self): + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + + # Add None instead of a node to the dialog. + table_edit_dialog = TableEditDialog(None) + # The window title of the dialog should be a string with an error. + assert table_edit_dialog.windowTitle() == "Node Input Error" + + def test_initial_attributes(self): + """ + Test the existence of some relevant attributes of the dialog. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + + # Create an existing and valid table node for testing. + table_node = TableNode("test", "localhost", "testuser", "testdb", 5432, 10000) + + # Add the table node to the dialog. + table_edit_dialog = TableEditDialog(table_node) + + # The selected table node should be the used table node. + assert table_edit_dialog.selected_table_node == table_node + # The database connection of the dialog should be the connection of the table node. + assert table_edit_dialog.database_connection == table_node._database_connection + + def test_checkbox_initialization(self): + """ + Test the correct initialization of the checkbox. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + + # Create an existing and valid table node for testing. + table_node = TableNode("test", "localhost", "testuser", "testdb", 5432, 10000) + + # Add the table node to the dialog. + table_edit_dialog = TableEditDialog(table_node) + + # Get the current configuration of the checkbox. + checkbox_configuration = global_app_configurator.get_single_configuration( + table_edit_dialog.checkbox_configuration_name) + + # If the configuration is True, the checkbox should be checked. + if checkbox_configuration is True: + assert table_edit_dialog.update_immediately_checkbox.isChecked() is True + + # If the configuration is False or None, the checkbox should not be checked. + else: + assert table_edit_dialog.update_immediately_checkbox.isChecked() is False + + def test_get_select_query(self): + """ + Test the function for getting the select query with an empty condition in the condition line edit and with a + text in the line edit. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + + # Create an existing and valid table node for testing. + table_node = TableNode("test", "localhost", "testuser", "testdb", 5432, 10000) + + # Add the table node to the dialog. + table_edit_dialog = TableEditDialog(table_node) + + # The current select query should contain a statement for selecting all values with limit 1000 in the given + # table. + assert table_edit_dialog.get_select_query() == "SELECT * FROM {} LIMIT 1000".format( + table_edit_dialog.selected_table_node.name) + + # Define a where condition for further testing. + test_where_condition = "test_column='test_value'" + # Set the where condition in the line edit.l + table_edit_dialog.condition_line_edit.setText(test_where_condition) + + # The current select query should contain the where condition in addition to the previous select query. + assert table_edit_dialog.get_select_query() == "SELECT * FROM {} WHERE {} LIMIT 1000".format( + table_edit_dialog.selected_table_node.name, test_where_condition) + + def test_update_statement(self): + """ + Test the current state of the update label, which contains the update query. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + + # Create an existing and valid table node for testing. + table_node = TableNode("test", "localhost", "testuser", "testdb", 5432, 10000) + + # Add the table node to the dialog. + table_edit_dialog = TableEditDialog(table_node) + + # The update label contains the update statement. Without changing a value in the table, the label contains + # just an UPDATE with the name of the table. + assert table_edit_dialog.update_label.text() == "UPDATE {}".format(table_edit_dialog.selected_table_node.name) + + def test_checkbox_change(self): + """ + Test the check changes in the checkbox for updating the table immediately. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + + # Create an existing and valid table node for testing. + table_node = TableNode("test", "localhost", "testuser", "testdb", 5432, 10000) + + # Add the table node to the dialog. + table_edit_dialog = TableEditDialog(table_node) + + # Set the checkbox to checked. + table_edit_dialog.update_immediately_checkbox.setChecked(True) + + # The update elements should be invisible now. + assert table_edit_dialog.update_label.isVisible() is False + assert table_edit_dialog.update_button.isVisible() is False + + # Set the checkbox to unchecked. + table_edit_dialog.update_immediately_checkbox.setChecked(False) + + # The update elements should be visible now. + assert table_edit_dialog.update_label.isVisible() is True + assert table_edit_dialog.update_button.isVisible() is True + + + diff --git a/tests/test_table_information.py b/tests/test_table_information.py new file mode 100644 index 0000000..52a5f86 --- /dev/null +++ b/tests/test_table_information.py @@ -0,0 +1,43 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.table_information import TableInformationDialog +from pygadmin.models.treemodel import TableNode + + +class TestTableInformationDialogMethods(unittest.TestCase): + """ + Test the functionality and methods of the table information dialog. + """ + + def test_dialog_without_node(self): + """ + Test the dialog without a valid node and None instead. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a table information dialog with None as node, which is invalid. + table_information_dialog = TableInformationDialog(None) + + # The window title of the widget should show an error. + assert table_information_dialog.windowTitle() == "Node Input Error" + + def test_initial_attributes_with_node(self): + """ + Test the initial attributes of the dialog with a valid table node. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Define a valid table node. + table_node = TableNode("test", "localhost", "testuser", "testdb", 5432, 10000) + # Create a table information dialog with the table node. + table_information_dialog = TableInformationDialog(table_node) + + # The selected table node of the dialog should be the created table node. + assert table_information_dialog.selected_table_node == table_node + # The parameter for full definition is not filled, so it should use the default value True. + assert table_information_dialog.full_definition is True diff --git a/tests/test_tablemodel.py b/tests/test_tablemodel.py new file mode 100644 index 0000000..19f70d5 --- /dev/null +++ b/tests/test_tablemodel.py @@ -0,0 +1,138 @@ +import unittest + +from PyQt5.QtCore import Qt + +from pygadmin.models.tablemodel import TableModel + + +class TestTableModelMethods(unittest.TestCase): + """ + Use a class for testing the different methods of the table model. + """ + + def test_initial_data_list(self): + """ + Test the creation of a table model and check the resulting data list. + """ + + # Define a data list. + test_data = [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], ["row D", "row E", "row F"]] + table_model = TableModel(test_data) + # Check the data list of the model. + assert test_data == table_model.data_list + + def test_correct_row_count(self): + """ + Test the correct row count for a normal and valid data list. + """ + + test_data = [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], ["row D", "row E", "row F"]] + table_model = TableModel(test_data) + # Describe the row count as the length of the test data list - 1, because the header is not a row. + assert table_model.rowCount() == len(test_data) - 1 + + def test_correct_column_count(self): + """ + Test the correct column count for a normal and valid data list. + """ + + test_data = [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], ["row D", "row E", "row F"]] + table_model = TableModel(test_data) + # The first list contains all header elements and describes the number of columns. + assert table_model.columnCount() == len(test_data[0]) + + def test_data_type(self): + """ + Test with different types the correct result of the data in the table model. Almost every value should cause the + return of a string. + """ + + # Define different kinds of test data: Numbers in the header, booleans (and None) as first row, numbers as + # second and strings as third. + test_data = [[0, 1, 2], [True, False, None], [4, 5, 6], ["test", "1234", "meow"]] + table_model = TableModel(test_data) + + # Get the first item in the first row, check for a string and for the correct value. + first_row_item = table_model.data(table_model.index(0, 0)) + assert isinstance(first_row_item, str) + assert first_row_item == "True" + + # Get the first item in the second row, check for a string and for the correct value. + second_row_item = table_model.data(table_model.index(1, 0)) + assert isinstance(second_row_item, str) + assert second_row_item == "4" + + # Get the first item in the third row, check for a string and for the correct value. + third_row_item = table_model.data(table_model.index(2, 0)) + assert isinstance(third_row_item, str) + assert third_row_item == "test" + + # Get the first item in the fourth row, but this row does not exist, so it should return None. None is not + # casted to a string, so there is just a check for None. + fourth_row_item = table_model.data(table_model.index(3, 0)) + assert fourth_row_item is None + + def test_horizontal_header_data(self): + """ + Test the correct display of data with a horizontal header. + """ + + # Define a first list as extra list, because this list contains the header. + first_list = ["column 0", "column 1", "column 2"] + test_data = [first_list, ["row A", "row B", "row C"], ["row D", "row E", "row F"]] + table_model = TableModel(test_data) + + # Check every number in the header. + for header_description_number in range(len(first_list) - 1): + # Get the data in the header by a horizontal orientation. + header_data = table_model.headerData(header_description_number, Qt.Horizontal) + # Check, if the item in the header is the same as the item in the first list, which is the header list. + assert header_data == first_list[header_description_number] + + def test_vertical_header_data(self): + """ + Test the correct display of data with a vertical header. + """ + + test_data = [["column 0", "column 1", "column 2"], ["row A", "row B", "row C"], ["row D", "row E", "row F"]] + table_model = TableModel(test_data) + + # Check for every element in the test data list. + for row_number in range(len(test_data) - 1): + # Get the data at the row number with a vertical orientation. + header_data = table_model.headerData(row_number, Qt.Vertical) + # The data for a vertical header is numeric, so from 1 to whatever and because of this, off by one needs to + # be considered. + assert header_data == row_number + 1 + + def test_correct_row_count_with_empty_list(self): + """ + Test the correct row count with an empty data list. + """ + + test_data = [] + table_model = TableModel(test_data) + # For an empty list, the row count should be 0. + assert table_model.rowCount() == 0 + + def test_correct_column_count_with_empty_list(self): + """ + Test the correct column count with an empty data list. + """ + + test_data = [] + table_model = TableModel(test_data) + # For an empty data list, the column count should be 0. + assert table_model.columnCount() == 0 + + def test_false_type_input(self): + """ + Test the behavior of the table model for a data list, which is not a list. + """ + + # Define the test data as None. + test_data = None + table_model = TableModel(test_data) + # For a data list, which is not a list, the new and internal data list of the table model should be an empty + # list. + assert table_model.data_list == [] diff --git a/tests/test_tree.py b/tests/test_tree.py new file mode 100644 index 0000000..ff4b959 --- /dev/null +++ b/tests/test_tree.py @@ -0,0 +1,146 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.widgets.tree import TreeWidget +from pygadmin.connectionstore import global_connection_store +from pygadmin.models.treemodel import ServerNode + + +class TestTreeWidgetMethods(unittest.TestCase): + """ + Test the basic functionality, correct behavior and some functions of the tree widget. + """ + + def test_initial_attributes(self): + """ + Test some of the initial attributes of the widget. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create a tree widget. + tree_widget = TreeWidget() + + # At the start of the widget, there should not be a selected index. + assert tree_widget.selected_index is False + # Check for the existence and correct instance of the server node list. + assert isinstance(tree_widget.server_nodes, list) + + def test_find_new_relevant_parameters(self): + """ + Test the function for finding new relevant connection parameters. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create a tree widget. + tree_widget = TreeWidget() + + # Add potential existing nodes to the tree widget. + for connection_dictionary in tree_widget.find_new_relevant_parameters(): + new_server_node = ServerNode(connection_dictionary["Host"], + connection_dictionary["Host"], + connection_dictionary["Username"], + connection_dictionary["Port"], + connection_dictionary["Timeout"]) + tree_widget.server_nodes.append(new_server_node) + + # After the start and without new relevant connection parameters, the function should return an empty list. + assert tree_widget.find_new_relevant_parameters() == [] + + # Define new connection parameters, so new relevant parameters can be found. + new_connection_parameters = {"Host": "127.0.01", + "Database": "testdb", + "Port": 5432, + "Username": "testuser"} + + # Get the current number of the connection parameters in the connection store and use it as new position for new + # connection parameters. + position = global_connection_store.get_number_of_connection_parameters() + # Save the new defined connection parameters in the connection store. + global_connection_store.save_connection_parameters_in_yaml_file(new_connection_parameters) + # Get the new and relevant parameters out of the tree widget. + new_relevant_parameters = tree_widget.find_new_relevant_parameters(position)[0] + + # Check for the correct values for the given keys in the dictionary, because they should be identical. + assert new_connection_parameters["Database"] == new_relevant_parameters["Database"] + assert new_connection_parameters["Host"] == new_relevant_parameters["Host"] + assert new_connection_parameters["Port"] == new_relevant_parameters["Port"] + assert new_connection_parameters["Username"] == new_relevant_parameters["Username"] + + # Delete the new connection parameters as a clean up. + global_connection_store.delete_connection(new_connection_parameters) + + def test_create_new_server_node(self): + """ + Test the method for creating a new server node. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create a tree widget. + tree_widget = TreeWidget() + + # Define connection parameters for a new server node. + server_node_connection_parameters = {"Host": "testhost", + "Database": "testdb", + "Port": 5432, + "Username": "testuser", + "Timeout": 10000} + + # Create a new server node with the tree widget. + new_server_node = tree_widget.create_new_server_node(server_node_connection_parameters) + # As a result, the created node should be a server node. + assert isinstance(new_server_node, ServerNode) + + # Append the node to the list of server nodes, so the next assertion is checked correctly. + tree_widget.server_nodes.append(new_server_node) + # The creation of a new server node should return None, because there is a duplicate. + assert tree_widget.create_new_server_node(server_node_connection_parameters) is None + + def test_check_server_node_for_duplicate(self): + """ + Test the method for checking the parameters of a potentially new server node for a duplicate. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create a tree widget. + tree_widget = TreeWidget() + + # Define connection parameters for a new server node. + server_node_connection_parameters = {"Host": "testhost", + "Database": "testdb", + "Port": 5432, + "Username": "testuser", + "Timeout": 10000} + + # There should not be a server node with the connection parameters of the new server node. + assert tree_widget.check_server_node_for_duplicate(server_node_connection_parameters) is False + + # Create a new server node. + new_server_node = tree_widget.create_new_server_node(server_node_connection_parameters) + # Append the new node to the list of server nodes. + tree_widget.server_nodes.append(new_server_node) + # The check for a duplicate with the same and old parameters should return True, because there is a duplicate. + assert tree_widget.check_server_node_for_duplicate(server_node_connection_parameters) is True + + def test_append_new_node(self): + """ + Test the method for appending a new node in the tree widget. + """ + + # Create an app, because this is necessary for testing a QWidget. + app = QApplication(sys.argv) + # Create a tree widget. + tree_widget = TreeWidget() + + # Create a new server node. + server_node = ServerNode("testhost", "testhost", "testuser", 5432) + # Use the method of the tree widget for appending a new server node. + tree_widget.append_new_node(server_node) + + # Check for the existence of the new server node in the server node list of the tree widget. + assert server_node in tree_widget.server_nodes diff --git a/tests/test_treemodel.py b/tests/test_treemodel.py new file mode 100644 index 0000000..cf78ae5 --- /dev/null +++ b/tests/test_treemodel.py @@ -0,0 +1,82 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication + +from pygadmin.models.treemodel import ServerNode, DatabaseNode, SchemaNode, TablesNode, ViewsNode, TableNode, ViewNode + + +class TestTreeMethods(unittest.TestCase): + """ + Use a class for testing the methods of the tree and the tree model. + """ + + def test_create_server_node(self): + """ + Test the creation of a server node. + """ + + # A QApplication is necessary for the usage of a QPixmap, which is part of the Server Node. + app = QApplication(sys.argv) + # Create a test node with static connection parameters. + ServerNode(name="localhost", + host="localhost", + user="testuser", + database="testdb", + port=5432, + timeout=5000) + + def test_node_children(self): + # A QApplication is necessary for the usage of a QPixmap, which is part of the Server Node. + app = QApplication(sys.argv) + # Create a test node with static connection parameters. + server_node = ServerNode(name="localhost", + host="localhost", + user="testuser", + database="testdb", + port=5432, + timeout=5000) + + # The child at row and column 0 of a server node is a database node for a correct and fully functional database + # connection. + database_node = server_node.child(0, 0) + # Check for the right instance. + assert isinstance(database_node, DatabaseNode) + + # The child at row and column 0 of a database node is a schema node. + schema_node = database_node.child(0, 0) + # Check for the right instance. + assert isinstance(schema_node, SchemaNode) + + # The child at row and column 0 of a schema node is a tables node. + tables_node = schema_node.child(0, 0) + # Check for the right instance. + assert isinstance(tables_node, TablesNode) + # The child at row 1 and column 0 of a schema node is a views node. + views_node = schema_node.child(1, 0) + # Check for the right instance. + assert isinstance(views_node, ViewsNode) + + # The child at row and column 0 of a tables node is a table node. + table_node = tables_node.child(0, 0) + # Check for the right instance. + assert isinstance(table_node, TableNode) + # The child at row and column 0 of a views node is a view node. + view_node = views_node.child(0, 0) + # Check for the right instance. + assert isinstance(view_node, ViewNode) + + def test_invalid_connection_of_node(self): + # A QApplication is necessary for the usage of a QPixmap, which is part of the Server Node. + app = QApplication(sys.argv) + # Create a test node with static connection parameters. The host contains an invalid parameter, which results in + # an invalid database connection. + server_node = ServerNode(name="localhost", + host="local", + user="testuser", + database="testdb", + port=5432, + timeout=5000) + + # The child of a server node should be None for an invalid connection. + assert server_node.child(0, 0) is None diff --git a/tests/test_version_information_dialog.py b/tests/test_version_information_dialog.py new file mode 100644 index 0000000..44214b3 --- /dev/null +++ b/tests/test_version_information_dialog.py @@ -0,0 +1,27 @@ +import sys +import unittest + +from PyQt5.QtWidgets import QApplication, QLabel + +from pygadmin.widgets.version_information_dialog import VersionInformationDialog + + +class TestVersionInformationDialogMethods(unittest.TestCase): + """ + Test the methods and behavior of the version information dialog. + """ + + def test_initial_attributes(self): + """ + Test the initial attributes of the dialog. + """ + + # Create an app, because this is necessary for testing a QDialog. + app = QApplication(sys.argv) + # Create a version information dialog. + version_information_dialog = VersionInformationDialog() + + # The dialog should not be modal. + assert version_information_dialog.isModal() is False + # The version label should be a QLabel. + assert isinstance(version_information_dialog.version_label, QLabel) diff --git a/tests/test_widget_icon_adder.py b/tests/test_widget_icon_adder.py new file mode 100644 index 0000000..8e8bfe3 --- /dev/null +++ b/tests/test_widget_icon_adder.py @@ -0,0 +1,41 @@ +import sys +import unittest + +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QApplication, QWidget + +from pygadmin.widgets.widget_icon_adder import IconAdder + + +class TestWidgetIconAdderMethods(unittest.TestCase): + """ + Test the functionality of the widget icon adder. + """ + + def test_initial_attributes(self): + """ + Test the initial attributes of the icon adder. + """ + + # Create an app, because this is necessary for testing a Qt elements. + app = QApplication(sys.argv) + # Create an icon adder. + icon_adder = IconAdder() + # The icon should be a QIcon. + assert isinstance(icon_adder.window_icon, QIcon) + + def test_add_icon(self): + """ + Test the method for adding an icon. + """ + + # Create an app, because this is necessary for testing a Qt elements. + app = QApplication(sys.argv) + # Create an icon adder. + icon_adder = IconAdder() + # Create a test widget. + test_widget = QWidget() + # Add an icon to the test widget. + icon_adder.add_icon_to_widget(test_widget) + # The name of the window icon of the test widget and of the icon adder should be the same. + assert test_widget.windowIcon().name() == icon_adder.window_icon.name()