Initial github release

This commit is contained in:
Lea Laux 2020-11-18 11:13:49 +01:00 committed by KDV Admin
commit 49de912c15
77 changed files with 14165 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
venv/
virtualenv/
.idea/
__pycache__
*dist/
*build/
*egg-info/
*.tar.gz
*.log
connection_parameters.yaml
app_configuration.yaml
*.sql
*.txt

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.

22
README.md Normal file
View File

@ -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.

29
pygadmin/__init__.py Normal file
View File

@ -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())

4
pygadmin/__main__.py Normal file
View File

@ -0,0 +1,4 @@
import pygadmin
if __name__ == "__main__":
pygadmin.main()

View File

@ -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()

253
pygadmin/configurator.py Normal file
View File

@ -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()

View File

@ -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()

221
pygadmin/connectionstore.py Normal file
View File

@ -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()

270
pygadmin/database_dumper.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1{fill:#f9f9d1;}.cls-2{fill:#ac9230;}</style></defs><title>database</title><g id="_3" data-name="3"><path class="cls-1" d="M13.32,5.5A.87.87,0,0,0,13.5,5C13.5,3.9,11,3,8,3s-5.5.9-5.5,2a.87.87,0,0,0,.18.5.78.78,0,0,0,0,1,.78.78,0,0,0,0,1,.78.78,0,0,0,0,1,.78.78,0,0,0,0,1,.78.78,0,0,0,0,1,.87.87,0,0,0-.18.5C2.5,12.1,5,13,8,13s5.5-.9,5.5-2a.87.87,0,0,0-.18-.5.78.78,0,0,0,0-1,.78.78,0,0,0,0-1,.78.78,0,0,0,0-1,.78.78,0,0,0,0-1A.78.78,0,0,0,13.32,5.5Z"/><ellipse class="cls-2" cx="8" cy="5" rx="5.5" ry="2"/><path class="cls-2" d="M8,12c-2.56,0-4.71-.64-5.32-1.5a.87.87,0,0,0-.18.5C2.5,12.1,5,13,8,13s5.5-.9,5.5-2a.87.87,0,0,0-.18-.5C12.71,11.36,10.56,12,8,12Z"/><path class="cls-2" d="M8,9C5.44,9,3.29,8.36,2.68,7.5A.87.87,0,0,0,2.5,8C2.5,9.1,5,10,8,10s5.5-.9,5.5-2a.87.87,0,0,0-.18-.5C12.71,8.36,10.56,9,8,9Z"/></g></svg>

After

Width:  |  Height:  |  Size: 899 B

79
pygadmin/icons/editor.svg Normal file
View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="a">
<stop offset="0"/>
<stop stop-opacity="0" offset="1"/>
</linearGradient>
<linearGradient id="f" x2="0" y1="-150.7" y2="327.66" gradientUnits="userSpaceOnUse">
<stop stop-opacity="0" offset="0"/>
<stop offset=".5"/>
<stop stop-opacity="0" offset="1"/>
</linearGradient>
<linearGradient id="l" x1="21.043" x2="14.284" y1="42.833" y2="6.8334" gradientTransform="matrix(1.1379 0 0 1 -2.6609 0)" gradientUnits="userSpaceOnUse">
<stop stop-color="#dfdfdf" offset="0"/>
<stop stop-color="#fff" offset="1"/>
</linearGradient>
<linearGradient id="k" x1="26.612" x2="26.228" y1="28.083" y2="42.833" gradientUnits="userSpaceOnUse">
<stop stop-color="#939393" offset="0"/>
<stop stop-color="#424242" offset="1"/>
</linearGradient>
<linearGradient id="j" x1="6" x2="40.984" gradientUnits="userSpaceOnUse">
<stop stop-color="#a3a4a0" offset="0"/>
<stop stop-color="#888a85" offset="1"/>
</linearGradient>
<linearGradient id="i" x1="43.237" x2="45.319" y1="17.376" y2="22.251" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffd1d1" offset="0"/>
<stop stop-color="#ff1d1d" offset=".5"/>
<stop stop-color="#6f0000" offset="1"/>
</linearGradient>
<linearGradient id="h" x1="40.331" x2="42.018" y1="19.812" y2="22.625" gradientUnits="userSpaceOnUse">
<stop stop-color="#c1c1c1" offset="0"/>
<stop stop-color="#acacac" offset="1"/>
</linearGradient>
<linearGradient id="g" x1="19.893" x2="19.689" y1="31.172" y2="30.828" gradientUnits="userSpaceOnUse">
<stop offset="0"/>
<stop stop-color="#c9c9c9" offset="1"/>
</linearGradient>
<radialGradient id="e" cx="29.053" cy="27.641" r="3.2408" gradientTransform="matrix(2.9236 -3.9114e-24 2.4718e-23 2.0297 -61.555 -27.884)" gradientUnits="userSpaceOnUse">
<stop stop-color="#e7e2b8" offset="0"/>
<stop stop-color="#e7e2b8" stop-opacity="0" offset="1"/>
</radialGradient>
<radialGradient id="b" cx="605.71" cy="486.65" r="117.14" gradientTransform="matrix(-2.7744 0 0 1.9697 112.76 -872.89)" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
<radialGradient id="c" cx="605.71" cy="486.65" r="117.14" gradientTransform="matrix(2.7744 0 0 1.9697 -1891.6 -872.89)" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
<radialGradient id="d" cx="23.562" cy="40.438" r="19.562" gradientTransform="matrix(1 0 0 .34824 1.4398e-16 26.355)" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
</defs>
<g transform="matrix(.024176 0 0 .020868 45.128 40.154)">
<path d="m-1559.3-150.7h1339.6v478.36h-1339.6z" fill="url(#f)" opacity=".40206"/>
<path d="m-219.62-150.68v478.33c142.87 0.90045 345.4-107.17 345.4-239.2s-159.44-239.13-345.4-239.13z" fill="url(#c)" opacity=".40206"/>
<path d="m-1559.3-150.68v478.33c-142.87 0.90045-345.4-107.17-345.4-239.2s159.44-239.13 345.4-239.13z" fill="url(#b)" opacity=".40206"/>
</g>
<path d="m7.1639 4.5064h32.649c0.76258 0 1.3765 0.53245 1.3765 1.1938l2.4013 34.169 0.01246 2.3476c0 0.66139-0.61391 1.1938-1.3765 1.1938h-37.477c-0.76258 0-1.3765-0.53245-1.3765-1.1938l-0.01117-2.1669 2.425-34.349c0-0.66139 0.61392-1.1938 1.3765-1.1938z" fill="url(#l)" fill-rule="evenodd" stroke="url(#k)"/>
<path transform="matrix(.61661 0 0 .44037 10.614 13.943)" d="m43.125 40.438a19.562 6.8125 0 1 1 -39.125 0 19.562 6.8125 0 1 1 39.125 0z" fill="url(#d)" fill-rule="evenodd" opacity=".31579"/>
<path d="m4.65642,39.86827h37.68932a.67938,.67938 0 0,1 .67938.67938v1.71274a.67938,.67938 0 0,1 -.67938.67938h-37.68932a.67938,.67938 0 0,1 -.67938-.67938v-1.71274a.67938,.67938 0 0,1 .67938-.67938" fill="#a4a4a4" fill-rule="evenodd"/>
<path d="m3.92675,40.4428c0,0 .15086-.53033.70402-.57452h37.5646c.75431,0 .8046.7513.8046.7513s.0236-1.61957-1.28387-1.61957h-36.41188c-1.00575.08839-1.37747.77988-1.37747,1.4428z" fill="#fff" fill-rule="evenodd"/>
<path d="m6.25,5.73437-.25,4.39063c0,0 .3125-1.125 1-1.125h33.125c.70313-.01563.73438.3125.85938.82813l-.25-3.875c-.03125-.54688-.21875-.95313-.78125-.95313h-32.89063c-.45313,0-.76563.34375-.8125.73438z" fill="url(#j)" fill-rule="evenodd"/>
<path d="m7.81265,5.54045h31.13234c.72204,0 1.30332-.1521 1.30332.47412l2.27367,33.00851 .10018,2.70896c0,.62623-.13934.64424-.86137.64424h-36.89874c-.41268,0-.41943-.1064-.41943-.51165l-.01058-2.67044 2.29605-33.14836c0-.62623.36253-.50537 1.08457-.50537z" fill="none" opacity=".4386" stroke="#fff"/>
<path d="m9.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m13.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m17.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m21.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m25.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m29.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m33.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m37.5,2.5a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1 1,1 0 0,1 -1-1v-3a1,1 0 0,1 1-1" fill="#fce94f" fill-rule="evenodd" stroke="#886f00"/>
<path d="m9 12h29v1h-29z" fill-rule="evenodd" opacity=".2807"/>
<path d="m9 14.982h29v1h-29z" fill-rule="evenodd" opacity=".2807"/>
<path d="m9 18.004h13v1h-13z" fill-rule="evenodd" opacity=".2807"/>
<path d="m9 22.986h29v1h-29z" fill-rule="evenodd" opacity=".2807"/>
<path d="m9 26.008h29v1h-29z" fill-rule="evenodd" opacity=".2807"/>
<path d="m9 29.03h29v1h-29z" fill-rule="evenodd" opacity=".2807"/>
<path d="m9 32.052h8v1h-8z" fill-rule="evenodd" opacity=".2807"/>
<path d="m17.341 32.5 5.625-5.625 20.094-9.75c3.25-1.25 5.1875 3.375 2.3125 5l-20.031 9.375-8 1z" fill="#cb9022" fill-rule="evenodd" stroke="#5c410c"/>
<path d="m38.331 20s1.4375 0.09375 2 1.3438c0.57949 1.2878 0 2.6562 0 2.6562l5.0312-2.4688s1.452-0.88137 0.65625-2.8438c-0.78491-1.9356-2.6875-1.1562-2.6875-1.1562l-5 2.4688z" fill="url(#i)" fill-rule="evenodd"/>
<path d="m38.331 20s1.4375 0.09375 2 1.3438c0.57949 1.2878 0 2.6562 0 2.6562l2-1s0.82703-1.3189 0.21875-2.6875c-0.625-1.4062-2.2188-1.3125-2.2188-1.3125l-2 1z" fill="url(#h)" fill-rule="evenodd"/>
<path d="m18.768 31.781 4.5-4.5c1.5 0.8125 2.2812 2.1562 1.875 3.7188l-6.375 0.78125z" fill="url(#e)" fill-rule="evenodd"/>
<path d="m20.112 30.375-1.625 1.5938 2.3438-0.3125c0.21875-0.71875-0.1875-1.0625-0.71875-1.2812z" fill="url(#g)" fill-rule="evenodd"/>
<path d="m23.268 27.25 1.5625 1.25 15.387-7.3187c-0.44443-0.85604-1.2418-1.0846-1.9034-1.1623l-15.046 7.2309z" fill="#fff" fill-opacity=".36364" fill-rule="evenodd"/>
<path d="m25.143 31.062 0.1875-0.75 15.231-7.1296s-0.11016 0.61363-0.21588 0.74935l-15.203 7.1302z" fill-opacity=".36364" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 136.743 136.704" viewBox="0 0 136.743 136.704" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="a" cx="68.356" cy="68.329" r="63.55" gradientUnits="userSpaceOnUse">
<stop stop-color="#d4e4f7" offset="0"/>
<stop stop-color="#7fb5f7" offset="1"/>
</radialGradient>
</defs>
<path d="m136.74 68.33c0 34.835-27.326 64.902-62.045 68.094-34.731 3.192-67.231-21.229-73.584-55.577-6.353-34.339 15.08-68.931 48.735-78.37s70.043 8.891 82.458 41.57c2.941 7.74 4.436 16.003 4.436 24.283" fill="#5471af"/>
<path d="m131.91 68.33c0 34.631-28.917 63.549-63.549 63.549s-63.55-28.918-63.55-63.549 28.917-63.55 63.55-63.55 63.549 28.916 63.549 63.55" fill="url(#a)"/>
<path d="m125.78 43.627c5.136 29.927-12.74 59.992-41.484 69.786-30.486 10.388-64.792-4.966-77.541-34.495 6.126 35.736 42.872 59.229 77.882 49.782 36.15-9.757 56.043-50.603 41.143-85.073" fill="#68a0f2"/>
<path d="m108.049,40.118c-8.789,1.819-18.908-10.632-26.528-13.799-14.237-5.919-32.021-3.171-44.919,4.889-13.643,8.523-23.347,24.896-24.839,40.968-.005.046-.005.056-.002.025 1.008-9.733.328-18.882 4.034-28.29 6.141-15.595 19.423-27.805 35.435-32.71 16.556-5.072 34.661-1.845 48.923,7.82 4.404,2.986 21.358,18.311 7.896,21.097" fill="#b3e7fc"/>
<path d="m52.656,37.577v53.022c0,5.399-2.216,15.433 5.681,15.813 6.644.323 14.157-8.493 18.84-12.189 7.759-6.117 15.516-12.235 23.275-18.354 7.476-5.896 1.938-10.271-3.596-14.651l-25.002-19.799-8.049-6.374c-2.697-2.138-11.149-2.474-11.149,2.532" fill="#fff"/>
<path d="m54.657,33.564c-5.175,2.503-2.903,14.432-2.903,18.904v33.111c0,4.376-2.163,16.078 1.398,19.72 6.829,6.98 17.41-4.71 22.374-8.624 8.074-6.369 16.148-12.736 24.224-19.104 1.999-1.577 4.77-3.234 5.614-5.767 1.409-4.229-2.137-6.694-4.95-8.923l-23.795-18.84c-5.583-4.422-13.931-14.359-21.962-10.477m.962,71.296c-4.182-2.018-2.061-14.494-2.061-17.969v-33.113c0-3.473-2.082-14.782.62-17.698 5.79-6.25 16.196,5.314 20.227,8.507l23.503,18.61c1.675,1.326 4.416,2.885 5.466,4.862 1.966,3.713-2.338,6.201-4.742,8.098l-23.131,18.24c-4.494,3.544-13.235,13.68-19.882,10.463" fill="#5471af"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800" height="800" version="1.1" viewBox="0 0 800.00001 800.00001" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0 -252.36)" fill="#1c8adb" shape-rendering="auto">
<path d="m399.98 252.36a400.01 399.99 0 0 0 -399.98 400 400.01 399.99 0 0 0 399.98 400 400.01 399.99 0 0 0 400.02 -400 400.01 399.99 0 0 0 -400.02 -400zm0 33.191a366.84 366.82 0 0 1 366.85 366.81 366.84 366.82 0 0 1 -366.85 366.81 366.84 366.82 0 0 1 -366.82 -366.81 366.84 366.82 0 0 1 366.82 -366.81z" color="#000000" color-rendering="auto" image-rendering="auto" solid-color="#000000" style="isolation:auto;mix-blend-mode:normal"/>
<path transform="translate(0 252.36)" d="m384.2 88.998v264.17a50 50 0 0 0 -34.197 47.371 50 50 0 0 0 0.96094 9.5449l-137.64 79.465 15.803 27.369 137.39-79.322a50 50 0 0 0 33.482 12.943 50 50 0 0 0 50 -50 50 50 0 0 0 -34.197 -47.393v-264.15h-31.605z" color="#000000" color-rendering="auto" image-rendering="auto" solid-color="#000000" style="isolation:auto;mix-blend-mode:normal"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

56
pygadmin/icons/load.svg Normal file
View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="h" x2="0" y1="13.183" y2="16.19" gradientUnits="userSpaceOnUse">
<stop stop-color="#f9f9f9" offset="0"/>
<stop stop-color="#c9c9c9" offset="1"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0"/>
<stop stop-opacity="0" offset="1"/>
</linearGradient>
<linearGradient id="g" x2="0" y1="39" y2="48" gradientUnits="userSpaceOnUse">
<stop stop-opacity="0" offset="0"/>
<stop offset=".5"/>
<stop stop-opacity="0" offset="1"/>
</linearGradient>
<linearGradient id="i" x1="28.688" x2="11.575" y1="45" y2="15.83" gradientUnits="userSpaceOnUse">
<stop stop-color="#6f0000" offset="0"/>
<stop stop-color="#c00" offset="1"/>
</linearGradient>
<linearGradient id="j" x1="22.935" x2="22.809" y1="49.629" y2="36.658" gradientTransform="matrix(1.1447 0 0 .99775 -3.4661 1.0988)" gradientUnits="userSpaceOnUse">
<stop stop-color="#0a0a0a" stop-opacity=".498" offset="0"/>
<stop stop-color="#0a0a0a" stop-opacity="0" offset="1"/>
</linearGradient>
<linearGradient id="k" x1="11.566" x2="15.215" y1="22.292" y2="33.955" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity=".27451" offset="0"/>
<stop stop-color="#fff" stop-opacity=".07843" offset="1"/>
</linearGradient>
<radialGradient id="d" cx="3" cy="5.0172" r="21" gradientTransform="matrix(-1.2749e-8 1.7143 -2.1593 -1.46e-8 12.809 2.857)" gradientUnits="userSpaceOnUse">
<stop stop-color="#bdbdbd" offset="0"/>
<stop stop-color="#d0d0d0" offset="1"/>
</radialGradient>
<radialGradient id="e" cx="63.969" cy="14.113" r="23.097" gradientTransform="matrix(1.5647 -9.5143e-8 6.1772e-8 1.0159 -86.213 8.1461)" gradientUnits="userSpaceOnUse">
<stop stop-color="#ef2929" offset="0"/>
<stop stop-color="#c00" offset=".5"/>
<stop stop-color="#a40000" offset="1"/>
</radialGradient>
<radialGradient id="f" cx="7.2646" cy="8.3021" r="20.98" gradientTransform="matrix(0 1.208 -1.6272 0 26.372 8.2665)" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity=".4" offset="0"/>
<stop stop-color="#fff" stop-opacity="0" offset="1"/>
</radialGradient>
<radialGradient id="b" cx="3.9019" cy="43.447" r="-3.7638" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
<radialGradient id="c" cx="44.098" cy="43.447" r="3.7638" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
</defs>
<path d="m3.5062 8.5c-0.69027 0.00767-1.0004 0.34215-1.0004 1 0 5.5144 0.02628 9.7396-0.00581 14.75 1.4355 0 43-3.6997 43-5.2923v-6.4515c0-0.65785-0.5542-1.0077-1.2445-1h-20.256c-2.0475 0-3.4989-3.0066-5-3.0066h-15.494z" fill="url(#h)" stroke="url(#d)" stroke-linecap="round" stroke-linejoin="round"/>
<g transform="matrix(.95833 0 0 .66667 1 15)" opacity=".4">
<path d="m4 39h40v9h-40z" fill="url(#g)"/>
<path d="m44 39v8.9995c1.6546 0.01694 4-2.0163 4-4.5003s-1.8464-4.4992-4-4.4992z" fill="url(#c)"/>
<path d="m4 39v8.9995c-1.6546 0.01694-4-2.0163-4-4.5003s1.8464-4.4992 4-4.4992z" fill="url(#b)"/>
</g>
<path d="m2.1629 16.525c-1.0727 0.12369-0.49977 1.4021-0.5853 2.121 0.39253 8.4701 0.78893 16.769 1.179 25.24 0.34184 0.96569 1.5944 0.47116 2.3881 0.59375h39.581c1.0894-0.10665 0.63646-1.4077 0.7897-2.1523 0.39254-8.4701 0.78894-16.769 1.179-25.24-0.25117-0.95429-1.5195-0.42401-2.2631-0.5625h-42.268z" fill="url(#e)" stroke="url(#i)" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m2.0582 16.065 43.896 4e-4c0.63416 0 1.0452 0.44461 1.0452 0.99737l-1.2442 26.939c0 0.55276-0.51053 0.99775-1.1447 0.99775h-41.209c-0.63416 0-1.1447-0.445-1.1447-0.99775l-1.2442-26.939c0-0.55276 0.41099-0.99775 1.0452-0.99775z" fill="url(#j)" opacity=".4"/>
<path d="m46.5 17.5h-44l1.1562 24.531" fill="none" stroke="url(#f)" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m45.75 16c-14.55 0.07335-29.105-0.02115-43.656 0-1.8068 0.41112-0.83441 2.4666-0.97243 3.7715 0.22203 4.1634 0.32307 8.3449 0.62046 12.497 15.001-2.3628 29.927-4.6072 44.664-7.0497 0.78099-2.5866 0.78738-5.6706 0.65736-8.4292-0.22914-0.50456-0.78701-0.80084-1.3136-0.78953z" fill="url(#k)"/>
<path d="m6,10h11a1,1 0 0,1 1,1 1,1 0 0,1 -1,1h-11a1,1 0 0,1 -1-1 1,1 0 0,1 1-1" display="block" fill="#ef2929" opacity=".6"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

736
pygadmin/icons/pygadmin.svg Normal file
View File

@ -0,0 +1,736 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="270px" height="258px" viewBox="0 0 270 258" enable-background="new 0 0 270 258" xml:space="preserve"> <image id="image0" width="270" height="258" x="0" y="0"
xlink:href="data:image/png;base64,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
jo90FKNHj2b+/PlMnDiRESNGMGTIEJRKJe7u7ri5uXHjxg2am5upqKigoqKC3Nxc0tPTiY+P70yZ
5igEIAn4C5Do4Fj2wg14GTF62fbotm4wbNgwQkNDGTt2LBqNhkGDBuHSknNELpdTXV1NY2MjBoMB
g8HAxYsXKSwsJDMzk/qeWY9sxTlE3VrcnSTq77h3kAMRQB6iF589q3hvLXrgI8QtWm+BEzAPMaDL
2Ato9GuWGuAr7q5E9zt6AQYgroongHru/YNoa2kC0hGVwVPo/ZKkElgI/C9Q3gvoZ2sxAReAPyP6
sPRn66RV9PYH7F7hAWARMAcYg+jC7knvoNdNxMCoXEST6EngFKIHZ1+FL2Iym3mICuwAYDC944Ws
R8wwlwucRVxcMuhwENhvDb3hRegrcEHco/sBamAocB8wsKV4Az4txb2lvysijZ1oo7WJtlWrAfEB
rAMqEDXvdYg2/6qWz+uIW49rLcVhW2sfgTOidaYzeg9EpLMa0Trkjuj23ppQ1amltNJbQNwiNSIy
g2rAQBu9W0sZYn6XUkR66/kdFvh/exFGswa6s0IAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjAtMDgt
MjZUMTQ6MDc6MDcrMDI6MDBSVOTIAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIwLTA4LTI2VDE0OjA3
OjA3KzAyOjAwIwlcdAAAABt0RVh0aWNjOmNvcHlyaWdodABQdWJsaWMgRG9tYWlutpExWwAAACJ0
RVh0aWNjOmRlc2NyaXB0aW9uAEdJTVAgYnVpbHQtaW4gc1JHQkxnQRMAAAAVdEVYdGljYzptYW51
ZmFjdHVyZXIAR0lNUEyekMoAAAAOdEVYdGljYzptb2RlbABzUkdCW2BJQwAAAABJRU5ErkJggg==" />
</svg>

After

Width:  |  Height:  |  Size: 56 KiB

8
pygadmin/icons/save.svg Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg">
<path transform="matrix(1.0594 0 0 1.0594 -1.4619 -1.891)" d="m8.375 6.0625c-1.108 0-2 0.892-2 2v44.844c0 1.108 0.892 2 2 2h43.062c1.108 0 2-0.892 2-2v-40.25l-6.625-6.5938h-38.438z" fill="#7f7f7f" fill-rule="evenodd"/>
<path d="m15.914 4.6592v13.242c0 1.353 1.1888 2.4423 2.6654 2.4423h17.908c1.4766 0 2.6654-1.0893 2.6654-2.4423v-13.242h-23.239z" fill="#bfbfbf" fill-rule="evenodd" stroke="#333" stroke-width="3.125"/>
<path d="m20.916 7.9428h3.7888a1.3992 1.3992 0 0 1 1.3992 1.3992v6.2428a1.3992 1.3992 0 0 1 -1.3992 1.3992h-3.7888a1.3992 1.3992 0 0 1 -1.3992 -1.3992v-6.2428a1.3992 1.3992 0 0 1 1.3992 -1.3992" fill="#7f7f7f" fill-rule="evenodd" stroke="#333" stroke-width="1.875"/>
<path transform="matrix(1.0594 0 0 1.0594 -1.4619 -1.891)" d="m8.375 6.0625c-1.108 0-2 0.892-2 2v44.844c0 1.108 0.892 2 2 2h43.062c1.108 0 2-0.892 2-2v-40.25l-6.625-6.5938h-38.438z" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.9499"/>
<path d="m11.392 24.011h37.677a2.0351 2.0351 0 0 1 2.0351 2.0351v24.541a2.0351 2.0351 0 0 1 -2.0351 2.0351h-37.677a2.0351 2.0351 0 0 1 -2.0351 -2.0351v-24.541a2.0351 2.0351 0 0 1 2.0351 -2.0351" fill="#d9d9d9" fill-opacity=".75" fill-rule="evenodd" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.875"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1,.cls-3{fill:#f2f2f2;}.cls-2{fill:#aaa;}.cls-3{stroke:#aaa;}.cls-3,.cls-5{stroke-miterlimit:1;stroke-width:0.75px;}.cls-4{fill:#7b7b97;}.cls-5{fill:#def4fd;stroke:#7b7b97;}</style></defs><title>server</title><g id="_3" data-name="3"><rect class="cls-1" x="3.08" y="2.6" width="9.85" height="3.1" rx="1.13" ry="1.13"/><path class="cls-2" d="M11.8,3a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V3.73A.75.75,0,0,1,4.2,3h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V3.73a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-3" x1="6.37" y1="4.15" x2="4.27" y2="4.15"/><path class="cls-1" d="M4.2,6.45h7.6a1.13,1.13,0,0,1,1.13,1.13v.85A1.12,1.12,0,0,1,11.8,9.55H4.2A1.12,1.12,0,0,1,3.08,8.42V7.58A1.13,1.13,0,0,1,4.2,6.45Z"/><path class="cls-4" d="M11.8,6.82a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V7.57a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V7.57a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-5" x1="6.37" y1="8" x2="4.27" y2="8"/><path class="cls-1" d="M4.2,10.3h7.6a1.12,1.12,0,0,1,1.12,1.12v.85A1.12,1.12,0,0,1,11.8,13.4H4.2a1.13,1.13,0,0,1-1.13-1.13v-.85A1.12,1.12,0,0,1,4.2,10.3Z"/><path class="cls-2" d="M11.8,10.68a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75v-.85a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5v-.85a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-3" x1="6.37" y1="11.85" x2="4.27" y2="11.85"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 16 16"
version="1.1"
id="svg3957"
sodipodi:docname="server.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata3961">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1135"
id="namedview3959"
showgrid="false"
inkscape:zoom="20.727068"
inkscape:cx="34.149469"
inkscape:cy="15.86127"
inkscape:window-x="3840"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg3957" />
<defs
id="defs3934">
<style
id="style3932">.cls-1,.cls-3{fill:#f2f2f2;}.cls-2{fill:#aaa;}.cls-3{stroke:#aaa;}.cls-3,.cls-5{stroke-miterlimit:1;stroke-width:0.75px;}.cls-4{fill:#7b7b97;}.cls-5{fill:#def4fd;stroke:#7b7b97;}</style>
</defs>
<title
id="title3936">server</title>
<g
id="_3"
data-name="3">
<rect
class="cls-1"
x="3.08"
y="2.6"
width="9.85"
height="3.1"
rx="1.13"
ry="1.13"
id="rect3938" />
<path
class="cls-2"
d="M11.8,3a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V3.73A.75.75,0,0,1,4.2,3h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V3.73a1.5,1.5,0,0,0-1.5-1.5Z"
id="path3940" />
<line
class="cls-3"
x1="6.37"
y1="4.15"
x2="4.27"
y2="4.15"
id="line3942" />
<path
class="cls-1"
d="M4.2,6.45h7.6a1.13,1.13,0,0,1,1.13,1.13v.85A1.12,1.12,0,0,1,11.8,9.55H4.2A1.12,1.12,0,0,1,3.08,8.42V7.58A1.13,1.13,0,0,1,4.2,6.45Z"
id="path3944" />
<path
class="cls-4"
d="M11.8,6.82a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V7.57a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V7.57a1.5,1.5,0,0,0-1.5-1.5Z"
id="path3946" />
<line
class="cls-5"
x1="6.37"
y1="8"
x2="4.27"
y2="8"
id="line3948" />
<path
class="cls-1"
d="M4.2,10.3h7.6a1.12,1.12,0,0,1,1.12,1.12v.85A1.12,1.12,0,0,1,11.8,13.4H4.2a1.13,1.13,0,0,1-1.13-1.13v-.85A1.12,1.12,0,0,1,4.2,10.3Z"
id="path3950" />
<path
class="cls-2"
d="M11.8,10.68a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75v-.85a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5v-.85a1.5,1.5,0,0,0-1.5-1.5Z"
id="path3952" />
<line
class="cls-3"
x1="6.37"
y1="11.85"
x2="4.27"
y2="11.85"
id="line3954" />
</g>
<g
transform="matrix(0.0537099,0,0,0.05622765,-10.954031,-14.825518)"
id="g4019">
<path
style="fill:#ff0000;fill-rule:evenodd;stroke:#ff0000;stroke-width:32"
inkscape:connector-curvature="0"
id="a"
d="m 371.57,418.27 120.4,120.4" />
<use
y="0"
x="0"
style="fill:#ff0000;stroke:#ff0000"
transform="matrix(-1,0,0,1,863.55,0)"
width="744.09448"
height="1052.3622"
xlink:href="#a"
id="use4017" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 16 16"
version="1.1"
id="svg98"
sodipodi:docname="pending.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata102">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1135"
id="namedview100"
showgrid="false"
inkscape:zoom="29.3125"
inkscape:cx="13.572879"
inkscape:cy="5.7678743"
inkscape:window-x="3840"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g170" />
<defs
id="defs75">
<style
id="style73">.cls-1,.cls-3{fill:#f2f2f2;}.cls-2{fill:#aaa;}.cls-3{stroke:#aaa;}.cls-3,.cls-5{stroke-miterlimit:1;stroke-width:0.75px;}.cls-4{fill:#7b7b97;}.cls-5{fill:#def4fd;stroke:#7b7b97;}</style>
<linearGradient
id="c"
x1="32.231998"
x2="41.601002"
y1="52.825001"
y2="65.946999"
gradientUnits="userSpaceOnUse"
xlink:href="#a"
spreadMethod="reflect" />
<linearGradient
id="a">
<stop
stop-color="#a18930"
offset="0"
id="stop157" />
<stop
stop-color="#e3c565"
offset=".66667"
id="stop159" />
<stop
stop-color="#fffbcc"
offset="1"
id="stop161" />
</linearGradient>
<linearGradient
id="b"
x1="44.905998"
x2="51.25"
y1="38.794998"
y2="47.563999"
gradientUnits="userSpaceOnUse"
xlink:href="#a"
spreadMethod="reflect" />
</defs>
<title
id="title77">server</title>
<g
id="_3"
data-name="3">
<rect
class="cls-1"
x="3.08"
y="2.6"
width="9.85"
height="3.1"
rx="1.13"
ry="1.13"
id="rect79" />
<path
class="cls-2"
d="M11.8,3a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V3.73A.75.75,0,0,1,4.2,3h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V3.73a1.5,1.5,0,0,0-1.5-1.5Z"
id="path81" />
<line
class="cls-3"
x1="6.37"
y1="4.15"
x2="4.27"
y2="4.15"
id="line83" />
<path
class="cls-1"
d="M4.2,6.45h7.6a1.13,1.13,0,0,1,1.13,1.13v.85A1.12,1.12,0,0,1,11.8,9.55H4.2A1.12,1.12,0,0,1,3.08,8.42V7.58A1.13,1.13,0,0,1,4.2,6.45Z"
id="path85" />
<path
class="cls-4"
d="M11.8,6.82a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V7.57a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V7.57a1.5,1.5,0,0,0-1.5-1.5Z"
id="path87" />
<line
class="cls-5"
x1="6.37"
y1="8"
x2="4.27"
y2="8"
id="line89" />
<path
class="cls-1"
d="M4.2,10.3h7.6a1.12,1.12,0,0,1,1.12,1.12v.85A1.12,1.12,0,0,1,11.8,13.4H4.2a1.13,1.13,0,0,1-1.13-1.13v-.85A1.12,1.12,0,0,1,4.2,10.3Z"
id="path91" />
<path
class="cls-2"
d="M11.8,10.68a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75v-.85a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5v-.85a1.5,1.5,0,0,0-1.5-1.5Z"
id="path93" />
<line
class="cls-3"
x1="6.37"
y1="11.85"
x2="4.27"
y2="11.85"
id="line95" />
</g>
<g
transform="matrix(0.16409412,-0.01078763,0.01130135,0.15663493,-23.405623,-122.54525)"
id="g170">
<path
style="fill:#2989ff;stroke:url(#b);stroke-width:2;fill-opacity:1"
inkscape:connector-curvature="0"
d="m 158.39,822.8 c 6.1991,7e-5 11.299,1.9339 15.3,5.8014 4.001,3.8675 6.0012,8.5012 6.0013,13.901 -4e-5,3.6662 -0.86674,6.9326 -2.6001,9.7992 -1.2675,2.0671 -3.1016,4.067 -5.5023,5.9998 -1.5992,1.2675 -3.1983,2.5014 -4.7974,3.7018 -1.4018,1.3998 -2.1027,3.0996 -2.1027,5.0995 -3e-5,1.3326 0.0335,2.1658 0.10071,2.4994 l -14.301,1.4008 c -0.26653,-2.1342 -0.39979,-4.5674 -0.39978,-7.2998 -1e-5,-0.46791 -1e-5,-0.83514 0,-1.1017 -1e-5,-5.3324 2.15,-9.3322 6.4499,-12 4.2999,-2.6678 6.4499,-5.2002 6.4499,-7.5989 -3e-5,-1.534 -0.66633,-2.8676 -1.9989,-4.0009 -1.33257,-1.1333 -2.7659,-1.6998 -4.2999,-1.6998 -1.8677,6e-5 -3.4678,0.46647 -4.8004,1.3992 -1.3326,0.93273 -2.5991,2.266 -3.7994,3.9993 l -11.801,-8.8989 c 5.601,-7.3343 12.968,-11.002 22.101,-11.002 z m -0.20141,50.702 10.303,10.3 -10.303,10.501 -10.498,-10.501 z"
id="path168" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 16 16"
version="1.1"
id="svg98"
sodipodi:docname="valid.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata102">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>server</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1135"
id="namedview100"
showgrid="false"
inkscape:zoom="14.65625"
inkscape:cx="-16.875309"
inkscape:cy="20.154025"
inkscape:window-x="3840"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg98" />
<defs
id="defs75">
<style
id="style73">.cls-1,.cls-3{fill:#f2f2f2;}.cls-2{fill:#aaa;}.cls-3{stroke:#aaa;}.cls-3,.cls-5{stroke-miterlimit:1;stroke-width:0.75px;}.cls-4{fill:#7b7b97;}.cls-5{fill:#def4fd;stroke:#7b7b97;}</style>
</defs>
<title
id="title77">server</title>
<g
id="_3"
data-name="3">
<rect
class="cls-1"
x="3.08"
y="2.6"
width="9.85"
height="3.1"
rx="1.13"
ry="1.13"
id="rect79" />
<path
class="cls-2"
d="M11.8,3a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V3.73A.75.75,0,0,1,4.2,3h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V3.73a1.5,1.5,0,0,0-1.5-1.5Z"
id="path81" />
<line
class="cls-3"
x1="6.37"
y1="4.15"
x2="4.27"
y2="4.15"
id="line83" />
<path
class="cls-1"
d="M4.2,6.45h7.6a1.13,1.13,0,0,1,1.13,1.13v.85A1.12,1.12,0,0,1,11.8,9.55H4.2A1.12,1.12,0,0,1,3.08,8.42V7.58A1.13,1.13,0,0,1,4.2,6.45Z"
id="path85" />
<path
class="cls-4"
d="M11.8,6.82a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V7.57a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V7.57a1.5,1.5,0,0,0-1.5-1.5Z"
id="path87" />
<line
class="cls-5"
x1="6.37"
y1="8"
x2="4.27"
y2="8"
id="line89" />
<path
class="cls-1"
d="M4.2,10.3h7.6a1.12,1.12,0,0,1,1.12,1.12v.85A1.12,1.12,0,0,1,11.8,13.4H4.2a1.13,1.13,0,0,1-1.13-1.13v-.85A1.12,1.12,0,0,1,4.2,10.3Z"
id="path91" />
<path
class="cls-2"
d="M11.8,10.68a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75v-.85a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5v-.85a1.5,1.5,0,0,0-1.5-1.5Z"
id="path93" />
<line
class="cls-3"
x1="6.37"
y1="11.85"
x2="4.27"
y2="11.85"
id="line95" />
</g>
<path
inkscape:connector-curvature="0"
style="fill:#00c000;fill-rule:evenodd;stroke-width:0.04087175;fill-opacity:1"
d="M 5.5512366,11.78177 9.2024687,15.899422 15.774686,7.6641188 14.496755,6.0628096 9.2024687,12.696804 7.0117294,9.9517027 Z"
id="path146" />
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

1
pygadmin/icons/table.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1{fill:#f2f2f2;}.cls-2{fill:#2195e7;}.cls-3{fill:none;stroke:#c1cbd5;stroke-linejoin:round;}.cls-3,.cls-4{stroke-width:0.75px;}.cls-4{fill:#def4fd;stroke:#2195e7;stroke-miterlimit:1;}</style></defs><title>table</title><g id="_2" data-name="2"><rect class="cls-1" x="2.92" y="3.65" width="10.15" height="8.71" rx="0.53" ry="0.53"/><path class="cls-2" d="M12.55,4a.15.15,0,0,1,.15.15v7.66a.15.15,0,0,1-.15.15H3.45a.15.15,0,0,1-.15-.15V4.17A.15.15,0,0,1,3.45,4h9.1m0-.75H3.45a.9.9,0,0,0-.9.9v7.66a.9.9,0,0,0,.9.9h9.1a.9.9,0,0,0,.9-.9V4.17a.9.9,0,0,0-.9-.9Z"/><line class="cls-3" x1="3.32" y1="9.43" x2="12.69" y2="9.43"/><line class="cls-3" x1="8.01" y1="7.09" x2="8" y2="11.97"/><line class="cls-4" x1="8.01" y1="4.03" x2="8" y2="6.58"/><line class="cls-4" x1="12.68" y1="6.74" x2="3.32" y2="6.74"/></g></svg>

After

Width:  |  Height:  |  Size: 885 B

1
pygadmin/icons/view.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1{fill:#e3ff8d;stroke:#9cc71c;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.75px;}</style></defs><title>view</title><g id="_2" data-name="2"><path class="cls-1" d="M2.71,2.41H13.1a.5.5,0,0,1,.5.5V13.1a.5.5,0,0,1-.5.5H2.9a.5.5,0,0,1-.5-.5V2.71A.3.3,0,0,1,2.71,2.41Z"/><rect class="cls-1" x="4.33" y="4.44" width="7.33" height="7.13" rx="0.3" ry="0.3"/><rect class="cls-1" x="6.27" y="6.26" width="3.46" height="3.48" rx="0.3" ry="0.3"/></g></svg>

After

Width:  |  Height:  |  Size: 535 B

39
pygadmin/logger.py Normal file
View File

@ -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))

41
pygadmin/logging.yaml Normal file
View File

@ -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]

View File

118
pygadmin/models/lexer.py Normal file
View File

@ -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()

View File

@ -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())

View File

@ -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

View File

View File

@ -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))

View File

@ -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)

File diff suppressed because it is too large Load Diff

31
pygadmin/widgets/dock.py Normal file
View File

@ -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)

916
pygadmin/widgets/editor.py Normal file
View File

@ -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 = "<b>{}</b>{}".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)

View File

@ -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("<font color='{}'>{}</font>".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))

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

951
pygadmin/widgets/tree.py Normal file
View File

@ -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)

View File

@ -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__))

View File

@ -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)

79
setup.py Normal file
View File

@ -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",
]
},
)

View File

@ -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

View File

@ -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

View File

@ -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

152
tests/test_configurator.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

26
tests/test_dock.py Normal file
View File

@ -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)

279
tests/test_editor.py Normal file
View File

@ -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

View File

@ -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")

73
tests/test_lexer.py Normal file
View File

@ -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()

206
tests/test_main_window.py Normal file
View File

@ -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

167
tests/test_mdi_area.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

47
tests/test_start.py Normal file
View File

@ -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()

View File

@ -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)

146
tests/test_table_edit.py Normal file
View File

@ -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

View File

@ -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

138
tests/test_tablemodel.py Normal file
View File

@ -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 == []

146
tests/test_tree.py Normal file
View File

@ -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

82
tests/test_treemodel.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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()