Initial github release
13
.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1,4 @@
|
|||||||
|
import pygadmin
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pygadmin.main()
|
170
pygadmin/command_history_store.py
Normal 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
@ -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()
|
198
pygadmin/connectionfactory.py
Normal 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
@ -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
@ -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
|
297
pygadmin/database_query_executor.py
Normal 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)
|
1
pygadmin/icons/database.svg
Normal 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
@ -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 |
15
pygadmin/icons/execute.svg
Normal 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 |
7
pygadmin/icons/history.svg
Normal 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
@ -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
@ -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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" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 56 KiB |
8
pygadmin/icons/save.svg
Normal 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 |
1
pygadmin/icons/server.svg
Normal 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 |
126
pygadmin/icons/server_invalid.svg
Normal 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 |
150
pygadmin/icons/server_pending.svg
Normal 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 |
113
pygadmin/icons/server_valid.svg
Normal 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
@ -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
@ -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
@ -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
@ -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]
|
0
pygadmin/models/__init__.py
Normal file
118
pygadmin/models/lexer.py
Normal 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()
|
121
pygadmin/models/tablemodel.py
Normal 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())
|
344
pygadmin/models/treemodel.py
Normal 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
|
0
pygadmin/widgets/__init__.py
Normal file
338
pygadmin/widgets/command_history.py
Normal 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))
|
||||||
|
|
251
pygadmin/widgets/configuration_settings.py
Normal 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)
|
1056
pygadmin/widgets/connection_dialog.py
Normal file
31
pygadmin/widgets/dock.py
Normal 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
@ -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)
|
||||||
|
|
358
pygadmin/widgets/editor_appearance_settings.py
Normal 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))
|
||||||
|
|
500
pygadmin/widgets/main_window.py
Normal 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()
|
279
pygadmin/widgets/mdi_area.py
Normal 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
|
125
pygadmin/widgets/node_create_information.py
Normal 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
|
236
pygadmin/widgets/permission_information.py
Normal 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
63
pygadmin/widgets/search_replace_parent.py
Normal 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
|
207
pygadmin/widgets/search_replace_widget.py
Normal 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)
|
110
pygadmin/widgets/start_progress_dialog.py
Normal 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)
|
||||||
|
|
393
pygadmin/widgets/table_edit.py
Normal 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)
|
||||||
|
|
161
pygadmin/widgets/table_information.py
Normal 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
@ -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)
|
51
pygadmin/widgets/version_information_dialog.py
Normal 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__))
|
42
pygadmin/widgets/widget_icon_adder.py
Normal 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
@ -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",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
220
tests/test_command_history.py
Normal 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
|
||||||
|
|
||||||
|
|
169
tests/test_command_history_store.py
Normal 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
|
107
tests/test_configuration_settings.py
Normal 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
@ -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
|
||||||
|
|
503
tests/test_connection_dialog.py
Normal 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
|
172
tests/test_connectionfactory.py
Normal 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
|
||||||
|
|
251
tests/test_connectionstore.py
Normal 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
|
||||||
|
|
277
tests/test_database_dumper.py
Normal 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)
|
||||||
|
|
160
tests/test_database_query_executor.py
Normal 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
@ -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
@ -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
|
||||||
|
|
110
tests/test_editor_appearance_settings.py
Normal 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
@ -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
@ -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
@ -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
|
||||||
|
|
125
tests/test_node_create_information.py
Normal 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
|
32
tests/test_permission_information.py
Normal 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
|
109
tests/test_search_replace_widget.py
Normal 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
@ -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()
|
||||||
|
|
102
tests/test_start_progress_dialog.py
Normal 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
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
43
tests/test_table_information.py
Normal 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
@ -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
@ -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
@ -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
|
27
tests/test_version_information_dialog.py
Normal 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)
|
41
tests/test_widget_icon_adder.py
Normal 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()
|